Bastelprojekt: Desktop-App für Hue-Geräte
Hue-Geräte auf dem Desktop steuern - als Bastelprojekt mit Python
Kürzlich haben wir Euch hier ein kleines Python-Skript präsentiert, mit dem Ihr Hue-Geräte auf dem Desktop schlicht auflisten lassen könnt. Der Artikel war eine Neuauflage eines alten Beitrags, bei dem es noch ein Shell-Skript war. Und da ich zum einen tatsächlich ein schlankes Desktop-Tool für Hue brauche und mehr mit Python spielen möchte, wird aus dem Hue-Lister nun auch ein Hue-Steuerer - allerdings vorerst nur für die Hue-API 1.0. Oder sagen wir soll werden ;) Die Basis steht aber.
Bastelprojekt Hue-App
Auf diesen Artikel werden noch weitere folgen, ein kleines Basteltagebuch. Wichtig: Das wird hier weder eine professionelle App, die sich über irgendwelche Stores verteilen lassen wird, noch ein Python-Kurs. Die App ist im Grunde nur für mich selbst gedacht und wird bisweilen auch wenig optimalen Code enthalten - aufgeräumt wird am Ende. Wenn überhaupt, ist nicht so meins. Hauptsache es läuft. Und die Versionen, die ich hier poste funktionieren auch.
Neben dem jeweils aktuellen Skript und ein paar Worten zu neuen Funktionen wird es gegebenenfalls noch Hinweise zu interessanten Python-Problemchen geben. Nun, Dinge, die zumindest für mich nicht ganz trivial zu lösen waren.
Für Euch soll das Skript aber natürlich auch nützlich sein - wie es ist oder mit eigenen Anpassungen. Wenn es einmal nach Wunsch funktioniert, könnt Ihr jederzeit eine ausführbare (EXE-)Datei daraus bauen - wie, steht weiter unten. Die App wird aber sehr schlank bleiben und nie alle Hue-Features umsetzen! Wenn Ihr so etwas sucht: WinHue haben wir schon mal erwähnt (kostenlos, etwas unübersichtlich) und mit Hue Essentials gibt es auch eine an die Original-Hue-App angelehnte wirklich hübsche App mit vollem Funktionsumfang - allerdings mit Werbung und Kaufangeboten.
Aktueller Stand
Hier die bislang vorhandenen Funktionen:
- Hue-Geräte auflisten mit: -- Hersteller -- Typ -- Produkt -- Status
- Ein-/Ausschalten
- Helligkeit ändern
Ein/Aus, Hell/Dunkel - das deckt meinen Bedarf eigentlich schon. Der Slider ist sicherlich etwas ungewöhnlich, da er für alle Geräte gilt. Gewohnt: Jede Leuchte hat einen Slider, der in Echtzeit die Helligkeit anpasst. Hier: Der Slider gibt fix einen Wert vor und per Set-Button werden die gewünschten Leuchten angepasst. Das entspricht so eher meinem Workflow, hält die GUI schlank und war zunächst auch einfacher umzusetzen.
Der Status ist leider noch fix, zeigt also nur den Status bei Programmstart. Dafür hatte ich mal eine Lösung drin - wenn man einen automatischen Neustart als Lösung bezeichnen möchte. Hat funktioniert, war aber selbst mir zu unschön - ist aber noch auskommentiert im Code! Das Aktualisieren von Tkinter-Widgets hakt noch.
Dazu wird es wohl noch Buttons zum Ansteuern aller Geräte geben, vielleicht auch nur ausgewählter. Oder es müssen doch die Szenen rein, mal schauen. Auch Lichtfarbe und -temperatur stehen auf der Wunschliste.
Tools und Kompilieren
Zunächst mal die wichtigsten Infos rund um das Projekt.
Die hier genutzen Tools und Versionen:
- Windows 11
- Hue API 1.0
- phue 1.1
- Python 3.12.0
- Pip 32.2.1
- Requests 2.31.0
- PyMsgBox 1.0.9
- Pyinstaller 6.2.0
Wichtig dürfte vor allem die API-Version der Bridge sein. Das Listen könnte auch mit der 2.0-API funktionieren, die die Daten etwas anders formatiert ausliefert. Für die Unterscheidung ist eine kleine if-Abfrage drin, die beide Varianten in das gleiche interne Ergebnis umwandelt, mit dem dann weiter gearbeitet wird. Aber wenn irgendwelche JSON-Fehlermeldungen auftauchen, wäre das mein erster Tipp.
Das Steuern funktioniert jedoch nicht - und damit natürlich auch das Listen nicht: Für die Steuerung habe ich der Bequemlichkeit halber die Bibliothek phue genutzt -, die aber eben nur 1.0 kann.
Kompilieren: Um aus dem Skript eine einzelne, ausführbare Datei zu bauen, nutzt pyinstaller:
pyinstaller -F mein-hue-skript.py
Unter Windows bekommt Ihr dann eine EXE und unter Linux eine PYD.
Python-Bröckchen: if Lambda ...
Zumindest ein Problemchen möchte ich kurz festhalten: Das Ausführen per Button war etwas nervig. Wenn ich für den Button als Kommando eine "Licht ein"-Funktion übergeben habe, wurde diese immer schon beim Programmstart ausgeführt. Die Lösung: Das Ganze als Lambda-Ausdruck, also quasi eine spontane In-Line-Funktion. Das Problem dabei: Jeder Ein-Button benötigt natürlich die ID des Geräts.
Was bei der ersten, beim Programmstart aufgerufenen Variante noch funktionierte, lief dann aber schief: In der Lambda-Version wurde als ID immer 13 genommen - die letzte ID in meiner Liste. Das eigentliche Problem sind Lambda-Funktionen in Schleifen. In aller Kürze: Sie werden erst am Ende ausgewertet und wenn Sie einen iterierten Wert wie hier eine ID oder die übliche Zähler-Variable i nutzen, dann eben immer nur den Wert beim letzten Durchlauf.
Die Lösung:
on_button = Button(frame, text="On", command=lambda lid2=lid: [b.set_light(int(lid2),'on', True)])
Die Lambda-Funktion bekommt hier noch vor dem eigentlichen Befehl mit lid2=lid (lid für Light-ID) die ID in einer eigenständigen Variablen geliefert - lid selbst würde im Befehl eben immer 13 sein.
Wesentlich kompetenter erklärt findet Ihr das Thema hier bei GeeksForGeeks, allerdings auf Englisch.
Skript
Und nun das Skript - noch mal mit dem Warnhinweis: Der Code ist ein wenig unaufgeräumt, bisweilen sind auch auskommentierte Code-Zeilen drin, die ich mal zum Debuggen oder für Tests genutzt habe und vielleicht irgendwie hilfreich sind.
Hinweis 2: Bitte das Skript nicht per Copy&Paste ziehen - nehmt diese Datei stattdessen! Diese TXT-Datei muss noch nach .py umbenannt werden, einfach direkt beim Speichern vergeben. Copy&Paste funzt leider nicht (zumindest hier beim Testen nicht ...), weil das elende, elende Wordpress irgendwelchen Mist mit unsichtbaren Zeichen macht - und das elende Python unsichtbare Zeichen als Teil der Syntax versteht. So hatte ich hier zwei laut Dateisystem und Text-Vergleichs-Tool identische Dateien, von denen eine partout nicht funktionieren wollte ... Ich weiß, manch ein Entwickler sieht das anders, aber ich verabscheue bedeutungsgeschwängerten Whitespace.
### License: WTFPL - http://www.wtfpl.net/
import requests
import re
import sys
import pymsgbox
import os
from phue import Bridge
import json
from tkinter import *
from PIL import ImageTk, Image
from tkinter import Tk, Label, Button
## config.ini - create or use
file_name = "config.ini"
if not os.path.isfile(file_name):
#print(f"The file {file_name} is in the working directory.")
ip = pymsgbox.prompt('Hue Bridge IP', default='192.168.178.100')
key = pymsgbox.prompt('Hue API key', default='AB-1234567890abcdefghi')
with open(file_name, "w") as file:
file.write(f"var1: {ip}\nvar2: {key}")
else:
print ("foobar")
with open(file_name, "r") as file:
content = file.readlines()
ip = content[0].split(":")[1].strip()
key = content[1].split(":")[1].strip()
### positional arguments, old, just for reference
# url = f"http://{sys.argv[1]}/api/{sys.argv[2]}/lights"
### get light info
url = f"http://{ip}/api/{key}/lights"
response = requests.get(url)
url_switch = f"http://{ip}/api/{key}/lights"
b = Bridge(ip)
def switch_on():
requests.put(f"{url}/{i}/state", json={"on": True})
def switch_off():
requests.put(f"{url}/{i}/state", json={"on": False})
def process_light_info(light_number, light_details):
light_name = light_details.get('name', 'Unknown')
light_type = light_details.get('type', 'Unknown')
manufacturer_name = light_details.get('manufacturername', 'Unknown')
product_name = light_details.get('productname', 'Unknown')
light_status = light_details.get('state', {}).get('on', 'Unknown')
info_str = f"{light_number}. {light_name}\n\tType: {light_type}\n\tManufacturer: {manufacturer_name}, \n\tProduct: {product_name}\n\tStatus: {'On' if light_status else 'Off'}" # Updated info_str to include status
lights_info.append(info_str)
lights_data = response.json()
lights_info = []
### Hue data as dict or list?
if isinstance(lights_data, dict):
for light_number, light_details in lights_data.items():
process_light_info(light_number, light_details)
elif isinstance(lights_data, list):
for i, light_details in enumerate(lights_data):
process_light_info(i, light_details)
### print, for debugging
# print("\n".join(lights_info))
### pymsgbox, for debugging
#pymsgbox.alert(info, 'El Tutos Hue Lister')
info = "\n".join(lights_info)
def showinfo(lid):
pymsgbox.alert(lid)
## testing mouse scroll stuff ############################################
def mouse_wheel(event):
global count
# respond to Linux or Windows wheel event
if event.num == 5 or event.delta == -120:
count -= 1
if event.num == 4 or event.delta == 120:
count += 1
count = 0
## testing end - scrolling only when hovering scrollbar, dumb ...
#### Building the GUI, tkinter code
# window areas:
# canvas = everything
# label = info area
# frame = button area
root = Tk()
root.geometry("800x1200+100+100")
root.title("El Tutos Hue App")
## background image, just as reference
#bg = ImageTk.PhotoImage(Image.open("image.png"))
bgcolor = "white"
canvas = Canvas(root, background=bgcolor)
scroll_y = Scrollbar(root, orient="vertical", command=canvas.yview)
frame = Frame(canvas, background=bgcolor)
foobar = 10
### script restart as gross status update, meh
#def restart():
# os.execv(sys.executable, ['python'] + sys.argv)
### Slider
def sliderstate():
global dumm
dumm = brightness.get()
for i, entry in enumerate(lights_info):
# Find the first standalone number in quotes
match = re.search(r'\b(\d+)\b', entry)
# Check if a match is found
if match:
lid = match.group(1)
else:
# Set a default value if no match is found
lid = "Default"
label = Label(frame, text=entry, width=50, justify=LEFT, anchor=W, background=bgcolor)
label.grid(row=i+2, column=0, sticky=W)
on_button = Button(frame, text="On", command=lambda lid2=lid: [b.set_light(int(lid2),'on', True)])
## optional second command: , restart()
on_button.grid(row=i+2, column=2, sticky=E)
off_button = Button(frame, text="Off", command=lambda lid2=lid: [b.set_light(int(lid2),'on', False)])
off_button.grid(row=i+2, column=9, sticky=E)
# Actual sliders
slidername = Label(frame, text='Brightness', background=bgcolor, font='Helvetica 14 bold')
slidername.grid(row=3, column=19, sticky=E)
brightness = Scale(frame, from_=0, to=248, orient=VERTICAL, length=800)
brightness.set(0)
brightness.configure(background='white', width=90 )
brightness.grid(row=2, column=19, rowspan=30)
bri_go = Button(frame, text="Set", command=lambda lid2=lid: [sliderstate(), b.set_light(int(lid2), 'bri', dumm), print(dumm)])
bri_go.grid(row=i+2, column=12, sticky=E)
### testing scroll stuff - wheel binding
canvas.bind_all("<mousewheel>", mouse_wheel)
canvas.create_window(0, 0, anchor='nw', window=frame)
canvas.update_idletasks()
canvas.configure(scrollregion=canvas.bbox('all'), yscrollcommand=scroll_y.set)
canvas.pack(fill='both', expand=True, side='left')
scroll_y.pack(fill='y', side='right')
root.mainloop()
Übrigens: Das Auflisten wird hier quasi manuell erledigt - und natürlich wäre das mit über die phue-Bibliothek einfacher. Das ist zum einen ein Überbleibsel der ersten reinen Lister-Version. Zum anderen scheint muss ich mal schauen, wie es mit der Kompatibilität aussieht und in welche Richtung es geht.
Einstiegsbild-Hintergrund: Craiyon