Tinge is a mobile first application for controlling Philips Hue lights on Linux
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

main.py 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from typing import Union
  4. import wx
  5. import wx.lib.scrolledpanel as scrolled
  6. from tinge import Tinge, HueBridge, HueGroup, HueLight, HueUtils, is_bridge
  7. class Hui(wx.Frame):
  8. """This is the Hue GUI class
  9. Args:
  10. wx (Frame): Parent class
  11. """
  12. def redraw(*args):
  13. """Decorator used for redrawing the widgets in the sizer
  14. Returns:
  15. function: The decorated function
  16. """
  17. func = args[0]
  18. def wrapper(self, *wrapper_args):
  19. """The wrapper function for the decorator
  20. """
  21. self.sizer.Clear(delete_windows=True)
  22. func(self, *wrapper_args)
  23. self.sizer.Layout()
  24. return wrapper
  25. def __init__(self, *args, **kw):
  26. """Constructor
  27. """
  28. super().__init__(*args, **kw)
  29. self.m_on_icon: str = '☼'
  30. self.m_off_icon: str = '☾'
  31. self.m_unreachable_icon: str = '⚠'
  32. self.m_tinge: Tinge = Tinge()
  33. self.cur_bridge: Union[None, HueBridge] = None
  34. self.cur_group: Union[None, HueGroup] = None
  35. # create a panel in the frame
  36. self.pnl: scrolled.ScrolledPanel = scrolled.ScrolledPanel(self, -1, style=wx.VSCROLL)
  37. self.pnl.SetupScrolling()
  38. # and create a sizer to manage the layout of child widgets
  39. self.sizer: wx.BoxSizer = wx.BoxSizer(wx.VERTICAL)
  40. self.pnl.SetSizer(self.sizer)
  41. self.add_bridges()
  42. @redraw
  43. def add_bridges(self):
  44. """Add bridges to sizer, the entry point of the program
  45. """
  46. self.SetTitle('Tinge - All Bridges')
  47. all_unreachable: bool = True
  48. no_bridges: bool = True
  49. if self.m_tinge.get_bridges():
  50. no_bridges = False
  51. for bridge in self.m_tinge.get_bridges():
  52. if bridge.is_reachable():
  53. bridge.refresh_bridge()
  54. all_unreachable = False
  55. btn: wx.Button = wx.Button(self.pnl, label=str(bridge))
  56. self.sizer.Add(btn, 0, wx.EXPAND)
  57. self.Bind(wx.EVT_BUTTON,
  58. lambda event, mbridge=bridge: self.goto_bridge(mbridge), btn)
  59. else:
  60. label = "{} {} ({})".format(self.m_unreachable_icon, str(bridge), "unreachable")
  61. btn: wx.Button = wx.Button(self.pnl, label=label)
  62. self.sizer.Add(btn, 0, wx.EXPAND)
  63. if no_bridges or all_unreachable:
  64. label = "Discover bridge"
  65. btn: wx.Button = wx.Button(self.pnl, label=label)
  66. self.sizer.Add(btn, 0, wx.EXPAND)
  67. self.Bind(wx.EVT_BUTTON,
  68. lambda event: self.discover_new_bridges(), btn)
  69. @redraw
  70. def add_groups(self, groups: list[HueGroup]):
  71. """This will add the groups to the sizer, when coming down from a bridge, or up from a light
  72. Args:
  73. groups (list[HueGroup]): The groups to display
  74. """
  75. self.SetTitle("Tinge - {}".format(self.cur_bridge.m_name))
  76. bridge_btn: wx.Button = wx.Button(self.pnl, label="All Bridges")
  77. has_unattached: bool = len(self.cur_bridge.unattached_lights) > 0
  78. self.sizer.Add(bridge_btn, 0, wx.EXPAND)
  79. self.Bind(wx.EVT_BUTTON,
  80. lambda event: self.add_bridges(), bridge_btn)
  81. if has_unattached:
  82. group_label: wx.StaticText = wx.StaticText(self.pnl, label=" ⚯ Groups ⚯ ", style=wx.ALIGN_CENTER)
  83. self.sizer.Add(group_label, 0, wx.EXPAND)
  84. for group in groups:
  85. inner_sizer: wx.BoxSizer = wx.BoxSizer(orient=wx.HORIZONTAL)
  86. groupid: int = group.get_id()
  87. icon: str = self.m_off_icon
  88. if group.is_any_on():
  89. icon = self.m_on_icon
  90. toggle_btn: wx.Button = wx.Button(self.pnl, label=icon)
  91. inner_sizer.Add(toggle_btn, 1, wx.EXPAND)
  92. self.Bind(wx.EVT_BUTTON,
  93. lambda event, mgroupid=groupid: self.toggle_group(mgroupid), toggle_btn)
  94. label: str = "{}".format(str(group))
  95. group_btn: wx.Button = wx.Button(self.pnl, label=label, style=wx.BU_LEFT)
  96. inner_sizer.Add(group_btn, 4, wx.EXPAND)
  97. self.Bind(wx.EVT_BUTTON,
  98. lambda event, mgroupid=groupid: self.goto_group(mgroupid), group_btn)
  99. self.sizer.Add(inner_sizer, 0, wx.EXPAND)
  100. if has_unattached:
  101. unattached_label: wx.StaticText = wx.StaticText(self.pnl, label=" ⚬ Unattached lights ⚬ ",
  102. style=wx.ALIGN_CENTER)
  103. self.sizer.Add(unattached_label, 0, wx.EXPAND)
  104. for light in self.cur_bridge.unattached_lights:
  105. inner_sizer = wx.BoxSizer(orient=wx.HORIZONTAL)
  106. lightid: int = light.get_id()
  107. icon: str = self.m_off_icon
  108. if light.is_on():
  109. icon = self.m_on_icon
  110. elif not light.is_reachable():
  111. icon = self.m_unreachable_icon
  112. toggle_btn: wx.Button = wx.Button(self.pnl, label=icon)
  113. inner_sizer.Add(toggle_btn, 1, wx.EXPAND)
  114. self.Bind(wx.EVT_BUTTON,
  115. lambda event, mlightid=lightid: self.toggle_light_and_goto_group(mlightid, lights),
  116. toggle_btn)
  117. label: str = "{}".format(light)
  118. light_btn: wx.Button = wx.Button(self.pnl, label=label, style=wx.BU_LEFT)
  119. inner_sizer.Add(light_btn, 4, wx.EXPAND)
  120. self.Bind(wx.EVT_BUTTON,
  121. lambda event, mlightid=lightid: self.add_single_light(mlightid, True), light_btn)
  122. self.sizer.Add(inner_sizer, 0, wx.EXPAND)
  123. def add_manual_discovery_dialog(self) -> bool:
  124. self.sizer.Clear(delete_windows=True)
  125. found_any: bool = False
  126. text_entry: wx.TextEntryDialog = wx.TextEntryDialog(self.pnl, "Manually enter IP address of bridge:",
  127. caption="Auto discovery failure")
  128. warn_label: wx.StaticText = wx.StaticText(self.pnl, label="Waiting for Button Press on Bridge")
  129. if text_entry.ShowModal() == wx.ID_OK:
  130. ipaddress: str = text_entry.GetValue()
  131. if is_bridge(ipaddress):
  132. self.sizer.Add(warn_label, 0, wx.ALIGN_CENTER)
  133. self.sizer.Layout()
  134. user_or_error = HueUtils.connect(ipaddress)
  135. while user_or_error.is_error():
  136. user_or_error = HueUtils.connect(ipaddress)
  137. self.m_tinge.append_bridge(HueBridge(ipaddress, user_or_error.get_user(), ipaddress))
  138. found_any = True
  139. self.m_tinge.write_all_bridges_to_conf()
  140. else:
  141. label = "Supplied IP Address did not match a Bridge.",
  142. failure_msg: wx.GenericMessageDialog = wx.GenericMessageDialog(self.pnl, label, caption="Try again!")
  143. failure_msg.ShowModal()
  144. return found_any
  145. @redraw
  146. def add_lights(self, lights: list[HueLight]):
  147. """This will add the lights from a group to the sizer
  148. Args:
  149. lights (list[HueLight]): The lights to display
  150. """
  151. self.SetTitle("Tinge - {}".format(self.cur_group))
  152. group_btn: wx.Button = wx.Button(self.pnl, label=str(self.cur_bridge))
  153. self.sizer.Add(group_btn, 0, wx.EXPAND)
  154. self.Bind(wx.EVT_BUTTON,
  155. lambda event: self.add_groups(self.cur_bridge.get_groups()), group_btn)
  156. for light in lights:
  157. inner_sizer = wx.BoxSizer(orient=wx.HORIZONTAL)
  158. lightid: int = light.get_id()
  159. icon: str = self.m_off_icon
  160. if light.is_on():
  161. icon = self.m_on_icon
  162. elif not light.is_reachable():
  163. icon = self.m_unreachable_icon
  164. toggle_btn: wx.Button = wx.Button(self.pnl, label=icon)
  165. inner_sizer.Add(toggle_btn, 1, wx.EXPAND)
  166. self.Bind(wx.EVT_BUTTON,
  167. lambda event, mlightid=lightid: self.toggle_light_and_goto_group(mlightid, lights),
  168. toggle_btn)
  169. label: str = "{}".format(light)
  170. light_btn: wx.Button = wx.Button(self.pnl, label=label, style=wx.BU_LEFT)
  171. inner_sizer.Add(light_btn, 4, wx.EXPAND)
  172. self.Bind(wx.EVT_BUTTON,
  173. lambda event, mlightid=lightid: self.add_single_light(mlightid), light_btn)
  174. self.sizer.Add(inner_sizer, 0, wx.EXPAND)
  175. @redraw
  176. def add_single_light(self, lightid: int, unattached: bool = False):
  177. """Call back for light button
  178. Args:
  179. lightid (int): The light id of the light to display
  180. unattached (bool, optional): Is the light unattached to any group?
  181. """
  182. light: HueLight = self.cur_bridge.get_light_by_id(lightid)
  183. self.SetTitle("Tinge - {}".format(light))
  184. is_on: bool = light.is_on()
  185. if unattached:
  186. group_btn: wx.Button = wx.Button(self.pnl, label=str(self.cur_bridge))
  187. self.sizer.Add(group_btn, 0, wx.EXPAND)
  188. self.Bind(wx.EVT_BUTTON,
  189. lambda event: self.add_groups(self.cur_bridge.get_groups()), group_btn)
  190. else:
  191. group: HueGroup = self.cur_group
  192. group_btn: wx.Button = wx.Button(self.pnl, label=str(group))
  193. self.sizer.Add(group_btn, 0, wx.EXPAND)
  194. self.Bind(wx.EVT_BUTTON,
  195. lambda event: self.goto_group(self.cur_group.get_id()), group_btn)
  196. # Toggle
  197. icon: str = self.m_off_icon
  198. if is_on:
  199. icon = self.m_on_icon
  200. elif not light.is_reachable():
  201. icon = self.m_unreachable_icon
  202. toggle_btn: wx.Button = wx.Button(self.pnl, label=icon)
  203. self.sizer.Add(toggle_btn, 0, wx.EXPAND)
  204. self.Bind(wx.EVT_BUTTON,
  205. lambda event, mlightid=lightid: self.toggle_light_and_goto_light(mlightid),
  206. toggle_btn)
  207. # Slider for brightness
  208. if is_on:
  209. if light.can_set_brightness():
  210. b_label: wx.StaticText = wx.StaticText(self.pnl, label="Brightness")
  211. self.sizer.Add(b_label, 0, wx.EXPAND)
  212. b_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_state().get_brightness(), minValue=1,
  213. maxValue=254)
  214. self.sizer.Add(b_slider, 0, wx.EXPAND)
  215. self.Bind(wx.EVT_SCROLL,
  216. lambda event: self.set_brightness(event, light.get_id()), b_slider)
  217. # Slider for colortemp
  218. if light.can_set_ct():
  219. c_label: wx.StaticText = wx.StaticText(self.pnl, label="Color Temperature")
  220. self.sizer.Add(c_label, 0, wx.EXPAND)
  221. c_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_ct(), minValue=153, maxValue=500)
  222. self.sizer.Add(c_slider, 0, wx.EXPAND)
  223. self.Bind(wx.EVT_SCROLL,
  224. lambda event: self.set_colortemp(event, light.get_id()), c_slider)
  225. # Slider for hue
  226. if light.can_set_hue():
  227. d_label: wx.StaticText = wx.StaticText(self.pnl, label="Hue")
  228. self.sizer.Add(d_label, 0, wx.EXPAND)
  229. d_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_hue(), minValue=0, maxValue=65535)
  230. self.sizer.Add(d_slider, 0, wx.EXPAND)
  231. self.Bind(wx.EVT_SCROLL,
  232. lambda event: self.set_hue(event, light.get_id()), d_slider)
  233. # Slider for saturation
  234. if light.can_set_sat():
  235. e_label: wx.StaticText = wx.StaticText(self.pnl, label="Saturation")
  236. self.sizer.Add(e_label, 0, wx.EXPAND)
  237. e_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_sat(), minValue=0, maxValue=254)
  238. self.sizer.Add(e_slider, 0, wx.EXPAND)
  239. self.Bind(wx.EVT_SCROLL,
  240. lambda event: self.set_saturation(event, light.get_id()), e_slider)
  241. rename_btn: wx.Button = wx.Button(self.pnl, label="Rename")
  242. self.sizer.Add(rename_btn, 0, wx.EXPAND)
  243. self.Bind(wx.EVT_BUTTON,
  244. lambda event, mlightid=lightid: self.rename_light_and_goto_light(mlightid, unattached),
  245. rename_btn)
  246. delete_btn: wx.Button = wx.Button(self.pnl, label="Delete")
  247. self.sizer.Add(delete_btn, 0, wx.EXPAND)
  248. self.Bind(wx.EVT_BUTTON,
  249. lambda event, mlightid=lightid: self.delete_light_and_goto_group(mlightid),
  250. delete_btn)
  251. def delete_light_and_goto_group(self, lightid):
  252. """Combo call back for delete and goto group
  253. Args:
  254. lightid (int): The light id of the light to delete
  255. """
  256. if self.get_ok_cancel_answer_from_modal("Are you sure you want to delete this light?"):
  257. light: HueLight = self.cur_bridge.get_light_by_id(lightid)
  258. light.delete()
  259. self.cur_bridge.remove_light(light)
  260. self.add_lights(self.cur_group.get_lights())
  261. else:
  262. self.add_single_light(lightid)
  263. def discover_new_bridges(self) -> bool:
  264. """Call back for button that is displayed if no bridges were found
  265. Returns:
  266. bool: True if we found any bridge, False otherwise
  267. """
  268. found_any: bool = False
  269. found_bridges: list[dict] = self.m_tinge.discover_new_bridges()
  270. if found_bridges:
  271. for bridge in found_bridges:
  272. user_or_error = HueUtils.connect(bridge['ipaddress'])
  273. while user_or_error.is_error():
  274. user_or_error = HueUtils.connect(bridge['ipaddress'])
  275. self.m_tinge.append_bridge(HueBridge(bridge['ipaddress'], user_or_error.get_user(), bridge['name']))
  276. found_any = True
  277. self.m_tinge.write_all_bridges_to_conf()
  278. self.add_bridges()
  279. else:
  280. found_any = self.add_manual_discovery_dialog()
  281. self.add_bridges()
  282. return found_any
  283. def get_ok_cancel_answer_from_modal(self, message: str) -> bool:
  284. """Display a message dialog and return ok or cancel
  285. Args:
  286. message (str): The message to display
  287. Returns:
  288. bool: The response from the user
  289. """
  290. with wx.MessageDialog(self.pnl, message, style=wx.OK | wx.CANCEL | wx.CANCEL_DEFAULT) as dlg:
  291. return dlg.ShowModal() == wx.ID_OK
  292. def get_text_answer_from_modal(self, message: str, cap: str, val: str = "") -> str:
  293. """Display a text entry and return the content
  294. Args:
  295. message (str): The message to display
  296. cap (str): The caption to display
  297. val (str, optional): The default value to display, defaults to the empty string
  298. Returns:
  299. str: The response from the user
  300. """
  301. with wx.TextEntryDialog(self.pnl, message, caption=cap, value=val) as dlg:
  302. dlg.ShowModal()
  303. answer: str = dlg.GetValue()
  304. return answer
  305. def goto_bridge(self, bridge: HueBridge):
  306. """Call back for a bridge button
  307. Args:
  308. bridge (HueBridge): The bridge to display
  309. """
  310. self.cur_bridge = bridge
  311. groups: list[HueGroup] = bridge.get_groups()
  312. if groups:
  313. self.add_groups(groups)
  314. else:
  315. self.add_lights(bridge.get_lights())
  316. def goto_group(self, groupid: int):
  317. """Call back for group button
  318. Args:
  319. groupid (int): The group id of the group to display
  320. """
  321. group = self.cur_bridge.get_group_by_id(groupid)
  322. self.cur_group = group
  323. self.add_lights(group.get_lights())
  324. def rename_light_and_goto_light(self, lightid, unattached: bool = False):
  325. """Combo call back to rename a light and display that light again
  326. Args:
  327. lightid ([type]): The light id of the light to rename/display
  328. """
  329. newname: str = self.get_text_answer_from_modal("Set new name", "New name:")
  330. if newname:
  331. self.cur_bridge.get_light_by_id(lightid).rename(newname)
  332. self.add_single_light(lightid, unattached)
  333. def set_brightness(self, event: wx.ScrollEvent, lightid: int):
  334. """Call back for brightness slider
  335. Args:
  336. event (wx.ScrollEvent): The scroll event to react to
  337. lightid (int): The light id of the light to adjust brightness of
  338. """
  339. bri: int = event.GetPosition()
  340. light: HueLight = self.cur_bridge.get_light_by_id(lightid)
  341. light.set_brightness(bri)
  342. def set_colortemp(self, event, lightid):
  343. """Call back for colortemp slider
  344. Args:
  345. event (wx.ScrollEvent): The scroll event to react to
  346. lightid (int): The light id of the light to adjust colortemp of
  347. """
  348. ct: int = event.GetPosition()
  349. light: HueLight = self.cur_bridge.get_light_by_id(lightid)
  350. light.set_ct(ct)
  351. def set_hue(self, event, lightid):
  352. """Call back for hue slider
  353. Args:
  354. event (wx.ScrollEvent): The scroll event to react to
  355. lightid (int): The light id of the light to adjust hue of
  356. """
  357. hue: int = event.GetPosition()
  358. light: HueLight = self.cur_bridge.get_light_by_id(lightid)
  359. light.set_hue(hue)
  360. def set_saturation(self, event, lightid):
  361. """Call back for saturation slider
  362. Args:
  363. event (wx.ScrollEvent): The scroll event to react to
  364. lightid (int): The light id of the light to adjust saturation of
  365. """
  366. sat: int = event.GetPosition()
  367. light: HueLight = self.cur_bridge.get_light_by_id(lightid)
  368. light.set_sat(sat)
  369. def toggle_group(self, groupid: int):
  370. """Toggle the lights of a group
  371. Args:
  372. groupid (int): The group id of the group to toggle
  373. """
  374. self.cur_bridge.get_group_by_id(groupid).toggle()
  375. self.add_groups(self.cur_bridge.get_groups())
  376. def toggle_light_and_goto_light(self, lightid):
  377. """Combo call back to toggle a light and display that light again
  378. Args:
  379. lightid ([type]): The light id of the light to toggle/display
  380. """
  381. self.cur_bridge.get_light_by_id(lightid).toggle()
  382. self.add_single_light(lightid)
  383. def toggle_light_and_goto_group(self, lightid: int, lights: list[HueLight]):
  384. """Combo call back for toggle and goto group
  385. Args:
  386. lightid (int): The light id of the light to toggle
  387. lights (list[HueLight]): The lights to display after toggle
  388. """
  389. self.cur_bridge.get_light_by_id(lightid).toggle()
  390. self.add_lights(lights)
  391. if __name__ == "__main__":
  392. app = wx.App()
  393. frm = Hui(None, title="Tinge")
  394. frm.Show()
  395. app.MainLoop()