From 273e11147717a65d9a9c882d99c587238bc544cc Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Wed, 5 May 2021 17:45:15 +0200 Subject: [PATCH] Add GUI --- main.py | 242 +++++++++++++++++++++++++++++++++- tinge/HueBridge/__init__.py | 121 +++++++++++++++-- tinge/HueGroup/__init__.py | 126 +++++++++++++++--- tinge/HueLight/__init__.py | 213 +++++++++++++++++++++++++++++- tinge/HueUtils/__init__.py | 27 ++++ tinge/UserOrError/__init__.py | 29 ++++ tinge/__init__.py | 20 ++- 7 files changed, 742 insertions(+), 36 deletions(-) diff --git a/main.py b/main.py index 1c39b89..8a74e91 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,241 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from tinge import Tinge +from typing import Union + +import wx +import wx.lib.scrolledpanel as scrolled + +from tinge import Tinge, HueBridge, HueGroup, HueLight + + +class Hui(wx.Frame): + """This is the Hue GUI class + + Args: + wx (Frame): Parent class + """ + + def __init__(self, *args, **kw): + """Constructor + """ + super().__init__(*args, **kw) + self.m_on_icon: str = '☼' + self.m_off_icon: str = '☾' + self.m_tinge: Tinge = Tinge() + self.m_bridges: list[HueBridge] = self.m_tinge.get_bridges() + self.cur_bridge: Union[None, HueBridge] = None + self.cur_group: Union[None, HueGroup] = None + # create a panel in the frame + self.pnl: scrolled.ScrolledPanel = scrolled.ScrolledPanel(self, -1, style=wx.VSCROLL) + self.pnl.SetupScrolling() + # and create a sizer to manage the layout of child widgets + self.sizer: wx.BoxSizer = wx.BoxSizer(wx.VERTICAL) + self.pnl.SetSizer(self.sizer) + self.add_bridges() + + def add_bridges(self): + """Add bridges to sizer, the entry point of the program + """ + self.sizer.Clear(delete_windows=True) + if self.m_bridges: + for bridge in self.m_bridges: + btn: wx.Button = wx.Button(self.pnl, label=str(bridge)) + self.sizer.Add(btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event, mbridge=bridge: self.goto_bridge(mbridge), btn) + else: + btn: wx.Button = wx.Button(self.pnl, label="Discover bridges") + self.sizer.Add(btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event: self.discover_new_bridges(), btn) + self.sizer.Layout() + + def add_groups(self, groups: list[HueGroup]): + """This will add the groups to the sizer, when coming down from a bridge, or up from a light + + Args: + groups (list[HueGroup]): The groups to display + """ + self.sizer.Clear(delete_windows=True) + bridge_btn: wx.Button = wx.Button(self.pnl, label=str(self.cur_bridge)) + self.sizer.Add(bridge_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event: self.add_bridges(), bridge_btn) + for group in groups: + inner_sizer: wx.BoxSizer = wx.BoxSizer(orient=wx.HORIZONTAL) + groupid: int = group.get_id() + icon: str = self.m_off_icon + if group.is_any_on(): + icon = self.m_on_icon + toggle_btn: wx.Button = wx.Button(self.pnl, label=icon) + inner_sizer.Add(toggle_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event, mgroupid=groupid: self.toggle_group(mgroupid), toggle_btn) + label: str = str(group) + group_btn: wx.Button = wx.Button(self.pnl, label=label) + inner_sizer.Add(group_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event, mgroupid=groupid: self.goto_group(mgroupid), group_btn) + self.sizer.Add(inner_sizer, 0, wx.EXPAND) + self.sizer.Layout() + + # noinspection PyDefaultArgument + def add_lights(self, lights: list[HueLight]): + """This will add the lights from a group to the sizer + + Args: + lights (list[HueLight]): The lights to display + """ + self.sizer.Clear(delete_windows=True) + group_btn: wx.Button = wx.Button(self.pnl, label=str(self.cur_group)) + self.sizer.Add(group_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event: self.add_groups(self.cur_bridge.get_groups()), group_btn) + for light in lights: + inner_sizer = wx.BoxSizer(orient=wx.HORIZONTAL) + lightid: int = light.get_id() + icon: str = self.m_off_icon + if light.is_on(): + icon = self.m_on_icon + toggle_btn: wx.Button = wx.Button(self.pnl, label=icon) + inner_sizer.Add(toggle_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event, mlightid=lightid, mlights=lights: self.toggle_light_and_goto_group(mlightid, + mlights), + toggle_btn) + label: str = "{}".format(light) + light_btn: wx.Button = wx.Button(self.pnl, label=label) + inner_sizer.Add(light_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event, mlightid=lightid: self.goto_light(mlightid), light_btn) + self.sizer.Add(inner_sizer, 0, wx.EXPAND) + self.sizer.Layout() + + def goto_bridge(self, bridge: HueBridge): + """Call back for a bridge button + + Args: + bridge (HueBridge): The bridge to display + """ + self.cur_bridge = bridge + groups: list[HueGroup] = bridge.get_groups() + if groups: + self.add_groups(groups) + else: + self.add_lights(bridge.get_lights()) + + def discover_new_bridges(self): + """Call back for button that is displayed if no bridges were found + """ + self.m_tinge.discover_new_bridges() + self.add_bridges() + + def toggle_group(self, groupid: int): + """Toggle the lights of a group + + Args: + groupid (int): The group id of the group to toggle + """ + self.cur_bridge.get_group_by_id(groupid).toggle() + self.add_groups(self.cur_bridge.get_groups()) + + def goto_group(self, groupid: int): + """Call back for group button + + Args: + groupid (int): The group id of the group to display + """ + group = self.cur_bridge.get_group_by_id(groupid) + self.cur_group = group + self.add_lights(group.get_lights()) + + def toggle_light_and_goto_group(self, lightid: int, lights: list[HueLight]): + """Combo call back for toggle and goto group + + Args: + lightid (int): The light id oof the light to toggle + lights (list[HueLight]): The lights to display after toggle + """ + self.cur_bridge.get_light_by_id(lightid).toggle() + self.add_lights(lights) + + def goto_light(self, lightid: int): + """Call back for light button + + Args: + lightid (int): The light id of the light to display + """ + light: HueLight = self.cur_bridge.get_light_by_id(lightid) + is_on: bool = light.is_on() + self.sizer.Clear(delete_windows=True) + group: HueGroup = self.cur_group + group_btn: wx.Button = wx.Button(self.pnl, label=str(group)) + self.sizer.Add(group_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event: self.goto_group(self.cur_group.get_id()), group_btn) + # Toggle + icon: str = self.m_off_icon + if is_on: + icon = self.m_on_icon + toggle_btn: wx.Button = wx.Button(self.pnl, label=icon) + self.sizer.Add(toggle_btn, 0, wx.EXPAND) + self.Bind(wx.EVT_BUTTON, + lambda event, mlightid=lightid: self.toggle_light_and_goto_light(mlightid), + toggle_btn) + + # Slider for brightness + if is_on and light.get_brightness() > 0: + b_label: wx.StaticText = wx.StaticText(self.pnl, label="Brightness") + self.sizer.Add(b_label, 0, wx.EXPAND) + b_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_state().get_brightness(), minValue=1, + maxValue=254) + self.sizer.Add(b_slider, 0, wx.EXPAND) + self.Bind(wx.EVT_SCROLL_THUMBRELEASE, + lambda event: self.set_brightness(event, light.get_id()), b_slider) + # Slider for colortemp + if is_on and light.can_set_ct(): + c_label: wx.StaticText = wx.StaticText(self.pnl, label="Color Temperature") + self.sizer.Add(c_label, 0, wx.EXPAND) + c_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_ct(), minValue=153, maxValue=500) + self.sizer.Add(c_slider, 0, wx.EXPAND) + self.Bind(wx.EVT_SCROLL_THUMBRELEASE, + lambda event: self.set_colortemp(event, light.get_id()), c_slider) + self.sizer.Layout() + + def set_brightness(self, event: wx.ScrollEvent, lightid: int): + """Call back for brightness slider + + Args: + event (wx.ScrollEvent): The scroll event to react to + lightid (int): The light id of the light to adjust brightness of + """ + bri: int = event.GetPosition() + light: HueLight = self.cur_bridge.get_light_by_id(lightid) + light.set_brightness(bri) + + def toggle_light_and_goto_light(self, lightid): + """Combo call back to toggle a light and display that light again + + Args: + lightid ([type]): The light id of the light to toggle/display + """ + self.cur_bridge.get_light_by_id(lightid).toggle() + self.goto_light(lightid) + + def set_colortemp(self, event, lightid): + """Call back for colortemp slider + + Args: + event (wx.ScrollEvent): The scroll event to react to + lightid (int): The light id of the light to adjust colortemp of + """ + ct: int = event.GetPosition() + light: HueLight = self.cur_bridge.get_light_by_id(lightid) + light.set_ct(ct) + if __name__ == "__main__": - m_tinge = Tinge() - bridge = m_tinge.get_bridges()[0] - print(bridge.discover_new_lights()) - print(bridge.append_new_lights()) + app = wx.App() + frm = Hui(None, title="Tinge") + frm.Show() + app.MainLoop() diff --git a/tinge/HueBridge/__init__.py b/tinge/HueBridge/__init__.py index 96c6a6e..57e6543 100644 --- a/tinge/HueBridge/__init__.py +++ b/tinge/HueBridge/__init__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- import json +from typing import Union from ..HueGroup import HueGroup from ..HueLight import HueLight @@ -8,13 +9,40 @@ from ..HueUtils import make_request class HueBridge: - def __init__(self, ipaddress: str, username: str): + """This class represents a Hue Bridge + """ + + def __init__(self, ipaddress: str, username: str, name: str = ""): + """ Constructor + + Args: + ipaddress (str): The ip address of the bridge + username (str): The username for this app for this bridge + name (str, optional): A human readable name for this bridge. Is set to ipaddress, if not supplied. + """ self.m_ipaddress: str = ipaddress self.m_username: str = username + if name: + self.m_name: str = name + else: + self.m_name = self.m_ipaddress self.m_lights: list[HueLight] = self.discover_lights() self.m_groups: list[HueGroup] = self.discover_groups() + def __str__(self) -> str: + """The string representation of this bridge + + Returns: + str: Returns the name + """ + return self.m_name + def discover_groups(self) -> list[HueGroup]: + """Get groups from Hue Api + + Returns: + list[HueGroup]: discovered groups + """ path: str = "{}/groups".format(self.m_username) response = make_request(self.m_ipaddress, path) groups: list[HueGroup] = list() @@ -27,6 +55,19 @@ class HueBridge: def create_group(self, lights: list[HueLight], name: str, group_type: str = "LightGroup", group_class: str = "Other") -> bool: + """Create a group from a list of lights + + Args: + lights (list[HueLight]): a list of lights to group + name (str): The name of the new group + group_type (str, optional): The group type can be “LightGroup”, “Room” or either “Luminaire” or + “LightSource” if a Multisource Luminaire is present in the system. + Defaults to "LightGroup". + group_class (str, optional): Category of Room Types. Defaults to "Other". + + Returns: + bool: True if creation was a success, otherwise False + """ path = "{}/groups".format(self.get_user()) method = "POST" data: dict = {'lights': [], 'name': name, 'type': group_type, 'class': group_class} @@ -44,6 +85,11 @@ class HueBridge: return False def discover_lights(self) -> list[HueLight]: + """Get lights from Hue API + + Returns: + list[HueLight]: List of discovered lights + """ path: str = "{}/lights".format(self.m_username) response = make_request(self.m_ipaddress, path) lights: list[HueLight] = list() @@ -51,11 +97,19 @@ class HueBridge: lights.append(HueLight(int(key), value, self.get_ipaddress(), self.get_user())) return lights - def discover_new_lights(self, device_id_list=None) -> bool: + def discover_new_lights(self, light_ids: Union[None, list[int]] = None) -> bool: + """Makes bridge search for new lights + + Args: + light_ids (Union[None, list[int]], optional): Either a list of light ids or None. Defaults to None. + + Returns: + bool: True if bridge has started looking for new lights, otherwise False + """ body: str = "" - if device_id_list is not None: + if light_ids is not None: body_dict = {'deviceid': []} - for device in device_id_list: + for device in light_ids: body_dict['deviceid'].append(device) body = json.dumps(body_dict) path: str = "{}/lights".format(self.m_username) @@ -64,6 +118,12 @@ class HueBridge: return 'success' in response.json()[0].keys() def append_new_lights(self) -> bool: + """If any new lights were discovered in discover_new_lights(), they can be appended to this bridges + list of lights with this function + + Returns: + bool: True if the request was ok, otherwise False + """ path: str = "{}/lights/new".format(self.m_username) response = make_request(self.m_ipaddress, path) for key, value in json.loads(response.text).items(): @@ -73,19 +133,60 @@ class HueBridge: self.m_lights.append(HueLight(int(key), response.json(), self.get_ipaddress(), self.get_user())) return response.ok - def get_groups(self): + def get_groups(self) -> list[HueGroup]: + """Return all the groups of this bridge + + Returns: + list[HueGroup]: A list of all groups owned by this bridge + """ return self.m_groups - def get_lights(self): + def get_lights(self) -> list[HueLight]: + """Get all the lights owned by this bridge + + Returns: + list[HueLight]: A flat list of all lights owned by this bridge + """ return self.m_lights - def get_light_by_id(self, id: int) -> HueLight: + def get_light_by_id(self, light_id: int) -> HueLight: + """Get a specific light + + Args: + light_id (int): The light id of the light to get + + Returns: + HueLight: The light + """ for light in self.m_lights: - if light.get_id() == id: + if light.get_id() == light_id: return light - def get_ipaddress(self): + def get_group_by_id(self, group_id: int) -> HueGroup: + """Get a specific group + + Args: + group_id (int): The group id of the group to get + + Returns: + HueGroup: The group + """ + for group in self.m_groups: + if group.get_id() == group_id: + return group + + def get_ipaddress(self) -> str: + """Get the ip address of this bridge + + Returns: + str: The ip address + """ return self.m_ipaddress - def get_user(self): + def get_user(self) -> str: + """A user, or username, is more like a password and is needed to authenticate with the Hue API + + Returns: + str: The username + """ return self.m_username diff --git a/tinge/HueGroup/__init__.py b/tinge/HueGroup/__init__.py index 05ddb0c..e240520 100644 --- a/tinge/HueGroup/__init__.py +++ b/tinge/HueGroup/__init__.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import json import requests @@ -9,8 +8,19 @@ from ..HueUtils import make_request class HueGroup: + """A class for groups + """ + class Action: + """The light state of one of the lamps in the group. + """ + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the data structure that concerns this Action + """ keys = data_slice.keys() self.m_on: bool = data_slice['on'] self.m_bri: int = data_slice['bri'] @@ -41,25 +51,40 @@ class HueGroup: self.m_colormode = str() class State: + """A hueGroup.State represents the collective state of the group + + """ + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the data structure that concerns this State + """ self.m_all_on: bool = data_slice['all_on'] self.m_any_on: bool = data_slice['any_on'] - def is_all_on(self) -> bool: - return self.m_all_on - - def is_any_on(self) -> bool: - return self.m_any_on - - def __init__(self, id: int, lights: list[HueLight], data: dict, parent_bridge_ip: str, parent_bridge_user: str): - self.m_id: int = id + def __init__(self, group_id: int, lights: list[HueLight], data: dict, parent_bridge_ip: str, + parent_bridge_user: str): + """Constructor + + Args: + group_id (int): The group id for this group + lights (list[HueLight]): The lights that is in this group + data (dict): The Hue API description of the group + parent_bridge_ip (str): ip address of the parent bridge + parent_bridge_user (str): username of the parent bridge + """ + self.m_id: int = group_id self.m_parent_bridge_ip = parent_bridge_ip self.m_parent_bridge_user = parent_bridge_user self.m_name: str = data['name'] self.m_lights = lights self.m_sensors: list[str] = data['sensors'] self.m_type: str = data['type'] - self.m_state: HueGroup.State = HueGroup.State(data['state']) + # Don't believe the api, it can claim that a light is on even if it is unreachable + state: dict = {'all_on': self.is_any_on(), 'any_on': self.is_any_on()} + self.m_state: HueGroup.State = HueGroup.State(state) self.m_recycle: bool = data['recycle'] if 'class' in data.keys(): self.m_class: str = data['class'] @@ -67,32 +92,100 @@ class HueGroup: self.m_class: str = str() self.m_action: HueGroup.Action(data['action']) - def __str__(self): + def __str__(self) -> str: + """String representation of the group + + Returns: + str: The group name + """ return self.m_name def get_id(self) -> int: + """Get the group id + + Returns: + int: The group id + """ return self.m_id + def get_lights(self) -> list[HueLight]: + """Get the lights that belong to this group + + Returns: + list[HueLight]: The lights + """ + return self.m_lights + def is_all_on(self) -> bool: - return self.m_state.is_all_on() + """We really check to make sure + + Returns: + bool: True if all lights are on and reachable, otherwise False + """ + # Dont believe the API, both on and reachable to be on + on = True + for light in self.m_lights: + if not light.is_on(): + on = False + return on def is_any_on(self) -> bool: - return self.m_state.is_any_on() + """Check if any of the lights are on and reachable + + Returns: + bool: True if any light in the group is on and reachable, otherwise False + """ + # Dont believe the API, both on and reachable to be on + on = False + for light in self.m_lights: + if light.is_on(): + on = True + return on def toggle(self): + """Toggle all lights of the group + """ for light in self.m_lights: light.toggle() self.update_state() def turn_off(self): + """turn off all lights in the group + """ state: str = '{"on": false}' self.set_state(state) def turn_on(self): + """Turn on all lights in the group + """ state: str = '{"on": true}' self.set_state(state) def set_state(self, state: str) -> requests.Response: + """Helper method to set the state of the group + + Args: + state (str): The state of the group + Possible attributes are: + * on: True/False + * bri: 0-254 + * hue: 0-65535 + * sat: 0-254 + * xy: [0.0-1.0,0.0-1.0] + * ct: 153-500 + * alert: "none"/"select"/"lselect" + * effect: "none"/"colorloop" + * transitiontime: 0-65535 (multiple of 100ms, default is 4) + * bri_inc: -254-254 + * sat_inc: -254-254 + * hue_inc: -65534-65534 + * ct_inc: -65534-65534 + * xy_inc: -0.5-0.5 + * scene: "Scene identifier" + + Returns: + requests.Response: The API response + """ path: str = "{}/groups/{}/action".format(self.m_parent_bridge_user, self.m_id) method: str = "PUT" response = make_request(self.m_parent_bridge_ip, path, method, state) @@ -100,6 +193,7 @@ class HueGroup: return response def update_state(self): - path: str = "{}/groups/{}".format(self.m_parent_bridge_user, self.m_id) - response = make_request(self.m_parent_bridge_ip, path) - self.m_state = HueGroup.State(json.loads(response.text)['state']) + """Update the state after a possible change + """ + state: dict = {'all_on': self.is_any_on(), 'any_on': self.is_any_on()} + self.m_state = HueGroup.State(state) diff --git a/tinge/HueLight/__init__.py b/tinge/HueLight/__init__.py index 401c20e..f0a2be0 100644 --- a/tinge/HueLight/__init__.py +++ b/tinge/HueLight/__init__.py @@ -9,8 +9,19 @@ from ..HueUtils import make_request class HueLight: + """A class representing a light + """ + class State: + """A class representing the state of a light + """ + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the data from the API response that concerns this state + """ keys = data_slice.keys() self.m_on: bool = data_slice['on'] if 'bri' in keys: @@ -29,21 +40,69 @@ class HueLight: self.m_mode: str = data_slice['mode'] self.m_reachable: bool = data_slice['reachable'] + def get_brightness(self) -> int: + """Get current brightness of the light + + Returns: + int: 0-254 + """ + return self.m_bri + + def get_ct(self) -> int: + """Get current color temp of the light + + Returns: + int: 0, 153-500, 0 means it cant do color temp + """ + return self.m_ct + def is_on(self) -> bool: + """Is this thing on? + + Returns: + bool: True if it is on, otherwise False + """ return self.m_on def is_reachable(self) -> bool: + """Can we reach this light, if not it has most likely been turned off with physical switch + + Returns: + bool: True if it is reachable, otherwise False + """ return self.m_reachable class SwUpdate: + """A class to describe software updates + """ + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the data from the API that concerns this SwUpdate + """ self.m_state: str = data_slice['state'] self.m_lastinstall: datetime = datetime.strptime(data_slice['lastinstall'], "%Y-%m-%dT%H:%M:%S") class Capabilites: + """A class for the light capabilities + """ + class Control: + """A class for the control object + """ + class ColorTemp: + """A class for the color temperature object + """ + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the data from the API that concerns this ColorTemp + """ keys = data_slice.keys() if 'min' in keys: self.m_min: int = data_slice['min'] @@ -54,7 +113,20 @@ class HueLight: else: self.m_max: int = 0 + def get_max(self) -> int: + """Get the max colortemp of this light + + Returns: + int: Max colortemp of this light, 0 means you can't change colortemp + """ + return self.m_max + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the Hue API data that concerns this Control + """ keys = data_slice.keys() if 'mindimlevel' in keys: self.m_mindimlevel: int = data_slice['mindimlevel'] @@ -69,30 +141,82 @@ class HueLight: else: self.m_ct = HueLight.Capabilites.Control.ColorTemp({}) + def get_colortemp(self): + """Get the colortemp object + + Returns: + HueLight.Capabilities.Control.ColorTemp: the colortemp object of this capability + """ + return self.m_ct + class Streaming: + """A class for the streaming object + """ + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the Hue API data that concerns this streaming object + """ self.m_renderer: bool = data_slice['renderer'] self.m_proxy: bool = data_slice['proxy'] def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the Hue API data that concerns this capabilities object + """ self.m_certified: bool = data_slice['certified'] self.m_control = HueLight.Capabilites.Control(data_slice['control']) self.m_streaming = HueLight.Capabilites.Streaming(data_slice['streaming']) + def get_control(self): + """Get the control object + + Returns: + HueLight.Capabilities.Control: the control object of this capability + """ + class Config: + """A class for the config object + """ + class Startup: + """A class for the startup object + """ + def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the Hue API data that concerns this startup + """ self.m_mode: str = data_slice['mode'] self.m_configured: bool = data_slice['configured'] def __init__(self, data_slice: dict): + """Constructor + + Args: + data_slice (dict): The part of the Hue API response that concerns this config object + """ self.m_archetype: str = data_slice['archetype'] self.m_function: str = data_slice['function'] self.m_direction: str = data_slice['direction'] self.m_startup = HueLight.Config.Startup(data_slice['startup']) - def __init__(self, id: int, data: dict, parent_bridge_ip: str, parent_bridge_user: str): - self.m_id: int = id + def __init__(self, light_id: int, data: dict, parent_bridge_ip: str, parent_bridge_user: str): + """Constructor + + Args: + light_id (int): The id of this light + data (dict): The response data from the Hue API + parent_bridge_ip (str): ip address of the parent bridge + parent_bridge_user (str): username of the parent bridge + """ + self.m_id: int = light_id self.m_parent_bridge_ip = parent_bridge_ip self.m_parent_bridge_user = parent_bridge_user self.m_state = HueLight.State(data['state']) @@ -109,28 +233,91 @@ class HueLight: self.m_productid: str = data['productid'] def __str__(self) -> str: + """String representation of this light + + Returns: + str: The name of the light + """ return self.m_name + def can_set_ct(self) -> bool: + """Check if we can set a color temp for this light + + Returns: + bool: True if we can, otherwise False + """ + return self.get_ct() > 0 + def get_state(self): + """Get the state object for this light + + Returns: + HueLight.State: The current state of affairs + """ return self.m_state - def get_id(self): + def get_id(self) -> int: + """Get the id of this light + + Returns: + int: The light id + """ return self.m_id def is_on(self) -> bool: - return self.get_state().is_on() + """Is it on though? + + Returns: + bool: True if it is both on and reachable, otherwise False + """ + # A light has to be both on and reachable + return self.get_state().is_on() and self.is_reachable() def is_reachable(self) -> bool: + """Is it reachable + + Returns: + bool: True if it is, False otherwise + """ return self.get_state().is_reachable() def set_state(self, state: str) -> requests.Response: + """A helper method to set the state of the light + + Args: + state (str): The state of the light + + Returns: + requests.Response: The response from the Hue API + """ path: str = "{}/lights/{}/state".format(self.m_parent_bridge_user, self.m_id) method: str = "PUT" response = make_request(self.m_parent_bridge_ip, path, method, state) self.update_state() return response + def set_brightness(self, bri: int): + """Set the brightness of the light + + Args: + bri (int): 0-254 + """ + state = '{{"bri":{0}}}'.format(bri) + self.set_state(state) + + def set_ct(self, colortemp: int): + """Set the colortemp of the light, if possible + + Args: + colortemp (int): 153-500 + """ + if self.can_set_ct(): + state = '{{"ct":{0}}}'.format(colortemp) + self.set_state(state) + def toggle(self): + """Toggle light + """ if self.is_reachable(): state: str = '{"on":true}' if self.is_on(): @@ -138,6 +325,24 @@ class HueLight: self.set_state(state) def update_state(self): + """See if anything has changed + """ path: str = "{}/lights/{}".format(self.m_parent_bridge_user, self.m_id) response = make_request(self.m_parent_bridge_ip, path) self.m_state = HueLight.State(json.loads(response.text)['state']) + + def get_brightness(self) -> int: + """Get currrent brightness + + Returns: + int: 0-254 + """ + return self.get_state().get_brightness() + + def get_ct(self) -> int: + """Get current colortemp + + Returns: + int: 0,153-500 + """ + return self.get_state().get_ct() diff --git a/tinge/HueUtils/__init__.py b/tinge/HueUtils/__init__.py index 57b7636..fe2077c 100644 --- a/tinge/HueUtils/__init__.py +++ b/tinge/HueUtils/__init__.py @@ -8,6 +8,14 @@ from ..UserOrError import UserOrError def connect(ipaddress: str) -> UserOrError: + """Connect this bridge to tinge and get the username + + Args: + ipaddress (str): ip address of the bridge + + Returns: + UserOrError: Error if it is not connected otherwise the username + """ user_or_error: UserOrError = UserOrError() body: str = '{{"devicetype":"{0}"}}'.format("tinge") path: str = "" @@ -25,11 +33,30 @@ def connect(ipaddress: str) -> UserOrError: def is_valid_config(filename: str) -> bool: + """Currently doesn't do much, but we ckan get mor elaborate checks later if we want + + Args: + filename (str): The filename to check + + Returns: + bool: True if we think it is ok, otherwise False + """ return os.path.exists(filename) and os.path.getsize(filename) > 0 def make_request(ipaddress: str, path: str, method: str = "GET", body: str = '') -> requests.Response: + """Helper function to make an API call to the Hue API on the bridge + + Args: + ipaddress (str): ip address of the bridge + path (str): the API endpoint + method (str, optional): HTTP method. Defaults to "GET". + body (str, optional): The body or parameters to the API call. Defaults to ''. + + Returns: + requests.Response: The response from the Hue API + """ rfunct = requests.get url = "http://{}/api/{}".format(ipaddress, path) if method == "PUT": diff --git a/tinge/UserOrError/__init__.py b/tinge/UserOrError/__init__.py index 8d646e1..09b0fe3 100644 --- a/tinge/UserOrError/__init__.py +++ b/tinge/UserOrError/__init__.py @@ -3,26 +3,55 @@ class UserOrError: + """Class that can be either a username or an error message from the bridge + """ def __init__(self): + """Constructor + """ self.UNKNOWNERROR = 9999 self.muser: str = str() self.merror: bool = True self.mcode: int = self.UNKNOWNERROR def get_error_code(self) -> int: + """Thhis is an error code if this an error + + Returns: + int: error code from Hue API + """ return self.mcode def get_user(self) -> str: + """Get the username + + Returns: + str: the username we stored + """ return self.muser def is_error(self) -> bool: + """Check if this is an error + + Returns: + bool: True if ther is an error or False otherwise + """ return self.merror def set_error(self, code: int): + """Set an error code + + Args: + code (int): An error code to set + """ self.merror = True self.mcode = code def set_user(self, username: str): + """Set the username + + Args: + username (str): Username to set + """ self.merror = False self.muser = username diff --git a/tinge/__init__.py b/tinge/__init__.py index e4e18ab..adf0745 100644 --- a/tinge/__init__.py +++ b/tinge/__init__.py @@ -12,7 +12,12 @@ from .UserOrError import UserOrError class Tinge: + """The class that keeps track of bridges and config + """ + def __init__(self): + """Constructor + """ self.m_bridges: list[HueBridge] = list() self.m_discovered: list[str] = list() self.m_config = os.path.join(os.environ['HOME'], ".config/tinge/config") @@ -22,10 +27,14 @@ class Tinge: self.write_all_bridges_to_conf() def create_confdir(self): + """Create the config dir if it does not allready exist + """ if not os.path.exists(os.path.dirname(self.m_config)): os.makedirs(os.path.dirname(self.m_config)) def discover_new_bridges(self): + """Use UPnP to discover bridges on the current network + """ upnp: UPnP = UPnP() discovered_devices = upnp.discover() if not discovered_devices: @@ -47,10 +56,17 @@ class Tinge: self.m_discovered.append(device.host) return - def get_bridges(self): + def get_bridges(self) -> list[HueBridge]: + """Get the bridges + + Returns: + list[HueBridge]: The bridges we keep track off + """ return self.m_bridges def read_bridges_from_file(self): + """Read config file and add back previously discovered bridges + """ if is_valid_config(self.m_config): with open(self.m_config, 'r') as configfile: mbridges = toml.loads(configfile.read()) @@ -61,6 +77,8 @@ class Tinge: self.m_discovered.append(key) def write_all_bridges_to_conf(self): + """Save to file + """ with open(self.m_config, 'w') as configfile: for bridge in self.m_bridges: configfile.write('["{}"]\nuser = "{}"\n'.format(bridge.get_ipaddress(), bridge.get_user()))