Compare commits

...

22 Commits

@ -3,24 +3,22 @@
Tinge is a mobile first application for controlling Philips Hue lights on Linux
## Installation
Install wxPython (don't use pip to install it) eg. for PostmarketOS:
On Debian/Mobian
```
sudo apk add py3-wxpython
```
or for Debian/Mobian:
```
sudo apt install python3-wxgtk4.0
wget -O - https://repo.mic.ke/PUBLIC.KEY | sudo gpg --output /usr/share/keyrings/micke-archive-unstable.gpg --dearmor
echo "deb [signed-by=/usr/share/keyrings/micke-archive-unstable.gpg] https://repo.mic.ke/debian/ unstable main
deb-src [signed-by=/usr/share/keyrings/micke-archive-unstable.gpg] https://repo.mic.ke/debian/ unstable main" | \
sudo tee /etc/apt/sources.list.d/debian-micke-unstable.list
sudo apt update && sudo apt install python3-tinge
```
clone this repository:
```
git clone https://code.smolnet.org/micke/tinge
```
Run the crude installer:
```
cd tinge
./install.sh
```
If you want to help with packaging in other format, please get in touch by commenting on the appropriate issue:
- [Alpine](https://code.smolnet.org/micke/tinge/issues/10)
- [Appimage](https://code.smolnet.org/micke/tinge/issues/22)
- [Arch](https://code.smolnet.org/micke/tinge/issues/20)
- [Flatpak](https://code.smolnet.org/micke/tinge/issues/21)
- [RPM](https://code.smolnet.org/micke/tinge/issues/9)
## Requirements
Requires a recent Python3 and pip3, most likely python >= 3.7. It is only tested with 3.9 on PostmarketOS on the PinePhone and on Debian Bullseye though.
@ -32,6 +30,11 @@ If you find any bugs (that is broken, existing features) please open an issue he
You can also join #tinge on irc.libera.chat or use the [kiwiirc webchat](https://kiwiirc.com/nextclient/irc.libera.chat/#tinge) to ask any questions.
## Donations
[![Donate using Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/micke/donate)
If you want, you can chip in for server costs on [Liberapay](https://liberapay.com/micke/donate).
## Screenshots
![Discover Bridge View](https://code.smolnet.org/micke/tinge/raw/branch/master/screenshots/scrot1.png)
![All Bridge View](https://code.smolnet.org/micke/tinge/raw/branch/master/screenshots/scrot2.png)

@ -2,22 +2,24 @@
- [x] [Show unattached lights](https://code.smolnet.org/micke/tinge/issues/1)
- [x] [Discover new lights](https://code.smolnet.org/micke/tinge/issues/2)
# 0.1.0
- [ ] [Create group](https://code.smolnet.org/micke/tinge/issues/3)
- [ ] [Move light between groups](https://code.smolnet.org/micke/tinge/issues/4)
# 0.0.2
- [x] [Manually add bridge](https://code.smolnet.org/micke/tinge/issues/5)
- [ ] [Manually delete bridge](https://code.smolnet.org/micke/tinge/issues/6)
- [x] [Manually delete bridge](https://code.smolnet.org/micke/tinge/issues/6)
# 0.0.3
- [ ] [pip package](https://code.smolnet.org/micke/tinge/issues/7)
- [x] [pip package](https://code.smolnet.org/micke/tinge/issues/7)
# 0.1.0
- [ ] [Create group](https://code.smolnet.org/micke/tinge/issues/3)
- [ ] [Move light between groups](https://code.smolnet.org/micke/tinge/issues/4)
# 1.0.0
- [ ] [Debian package](https://code.smolnet.org/micke/tinge/issues/8)
- [ ] [RPM](https://code.smolnet.org/micke/tinge/issues/9)
- [ ] [Alpine package](https://code.smolnet.org/micke/tinge/issues/10)
- [ ] [Custom icon/logo for project](https://code.smolnet.org/micke/tinge/issues/11)
- [ ] [Arch package](https://code.smolnet.org/micke/tinge/issues/20)
- [ ] [RPM](https://code.smolnet.org/micke/tinge/issues/9)
- [x] [Custom icon/logo for project](https://code.smolnet.org/micke/tinge/issues/11)
- [x] [Debian package](https://code.smolnet.org/micke/tinge/issues/8)
# 2.0.0
- [ ] [Schedules](https://code.smolnet.org/micke/tinge/issues/12)

@ -0,0 +1,5 @@
[Desktop Entry]
Type=Application
Name=Tinge
Exec=tinge
Icon=org.smolnet.tinge

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@ -0,0 +1,166 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="170.17047mm"
height="166.50186mm"
viewBox="0 0 170.17047 166.50186"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="org.smolnet.tinge.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:snap-others="false"
inkscape:snap-to-guides="false"
inkscape:snap-grids="false"
inkscape:object-nodes="false"
inkscape:zoom="0.48222205"
inkscape:cx="1046.1985"
inkscape:cy="1128.111"
inkscape:window-width="3440"
inkscape:window-height="1440"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
inkscape:lockguides="false" />
<defs
id="defs2">
<marker
style="overflow:visible"
id="Arrow1Lstart"
refX="0"
refY="0"
orient="auto"
inkscape:stockid="Arrow1Lstart"
inkscape:isstock="true">
<path
transform="matrix(0.8,0,0,0.8,10,0)"
style="fill:context-stroke;fill-rule:evenodd;stroke:context-stroke;stroke-width:1pt"
d="M 0,0 5,-5 -12.5,0 5,5 Z"
id="path11947" />
</marker>
<linearGradient
id="linearGradient5905"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop5903" />
</linearGradient>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-13.673282,-10.01244)">
<ellipse
style="fill:#ffffff;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:8, 2, 1, 2;stroke-dashoffset:0;stroke-opacity:1"
id="path18033"
cx="98.75856"
cy="93.263443"
rx="84.585197"
ry="82.750923" />
<g
id="g17717"
inkscape:export-filename="/home/micke/sources/tinge/data/icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
transform="translate(2.327225e-5,-2.0701957e-4)">
<path
style="fill:none;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 26.092287,73.074775 c 70.193335,-50.00393 70.193335,-50.00393 70.193335,-50.00393 l 0.581165,-0.312814"
id="path42" />
<path
style="fill:none;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 166.47895,73.07477 C 96.285622,23.070845 96.285622,23.070845 96.285622,23.070845 l -0.58117,-0.312814"
id="path42-3" />
</g>
<g
id="g17745"
transform="translate(0.20055697,-2.0701957e-4)"
inkscape:export-filename="/home/micke/sources/tinge/data/icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<path
id="path6410"
style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.74442"
d="M 121.50265,103.04602 A 25.417566,23.020323 0 0 1 96.085083,126.06634 25.417566,23.020323 0 0 1 70.667517,103.04602 25.417566,23.020323 0 0 1 96.085083,80.025698 25.417566,23.020323 0 0 1 121.50265,103.04602 Z" />
<path
id="rect7636"
style="fill:none;stroke:#000000;stroke-width:2.04433;stroke-linejoin:round"
d="m 83.574081,67.314621 h 25.425929 v 14.94211 H 83.574081 Z" />
<path
id="path10454"
style="fill-rule:evenodd;stroke-width:0.180298"
transform="matrix(0.99965484,-0.02627152,0.02730158,0.99962724,0,0)"
d="m 98.603306,69.474854 a 4.2533302,4.1503177 0 0 1 -4.25333,4.150317 4.2533302,4.1503177 0 0 1 -4.253331,-4.150317 4.2533302,4.1503177 0 0 1 4.253331,-4.150318 4.2533302,4.1503177 0 0 1 4.25333,4.150318 z" />
<path
id="rect10973"
style="fill:#ffffff;stroke-width:2.07357;stroke-linejoin:round"
d="M 84.609299,68.315971 H 107.96863 V 81.20656 H 84.609299 Z" />
<path
style="fill:none;stroke:#000000;stroke-width:2.04433;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 106.58519,73.865222 C 86.369848,75.251831 85.97181,75.608161 85.97181,75.608161"
id="path14714-0" />
<path
style="fill:none;stroke:#000000;stroke-width:2.04433;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 106.50582,77.709435 C 86.290472,79.096046 85.892434,79.452382 85.892434,79.452382"
id="path14714-3" />
<path
id="path13562"
style="fill:#ffffff;fill-rule:evenodd;stroke-width:0.218573"
d="m 98.125623,66.730598 a 2.0876291,1.9277372 0 0 1 -2.087629,1.927738 2.0876291,1.9277372 0 0 1 -2.087629,-1.927738 2.0876291,1.9277372 0 0 1 2.087629,-1.927737 2.0876291,1.9277372 0 0 1 2.087629,1.927737 z" />
<path
id="rect13810"
style="fill:#ffffff;stroke-width:1.36289;stroke-linecap:round"
d="m 93.913414,66.6595 h 4.235808 v 2.746707 h -4.235808 z" />
<path
style="fill:none;stroke:#000000;stroke-width:2.04433;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 106.56281,69.908446 C 86.347459,71.295059 85.949423,71.65139 85.949423,71.65139"
id="path14714" />
</g>
<ellipse
style="display:inline;opacity:0;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.30889;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path6182"
cx="122.49568"
cy="109.44332"
rx="44.78046"
ry="34.012333"
inkscape:export-filename="/home/micke/sources/tinge/data/icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<g
id="g17734"
inkscape:export-filename="/home/micke/sources/tinge/data/icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
transform="translate(2.327225e-5,-2.0701957e-4)">
<path
style="fill:none;stroke:#000000;stroke-width:2.04433;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.04433, 2.04433;stroke-dashoffset:0;stroke-opacity:1"
d="m 95.174916,129.92892 c -0.124802,18.27339 -0.124802,18.27339 -0.124802,18.27339"
id="path16430" />
<path
style="fill:none;stroke:#000000;stroke-width:2.04433;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.04433, 2.04433;stroke-dashoffset:0;stroke-opacity:1"
d="M 73.852586,123.2925 C 61.36663,137.12556 61.36663,137.12556 61.36663,137.12556"
id="path16792" />
<path
style="fill:none;stroke:#000000;stroke-width:2.04433;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.04433, 2.04433;stroke-dashoffset:0;stroke-opacity:1"
d="m 118.71865,123.50097 c 12.48596,13.83307 12.48596,13.83307 12.48596,13.83307"
id="path16792-9" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

@ -1,7 +1,26 @@
#!/usr/bin/env bash
sudo pip3 install -r ./requirements.txt
pip=pip3
python_version=3.9
if [[ -f /usr/bin/apk ]] || [[ -f /sbin/apk ]]; then # PostmarketOS/Alpine
sudo apk add py3-wxpython py3-pip
python_version=3.10
elif [[ -f /usr/bin/apt ]]; then # Mobian/Debian/Ubuntu
sudo apt install python3-wxgtk4 python3-pip
elif [[ -f /usr/bin/pacman ]]; then # Arch/Manjaro
sudo pacman -S python-wxpython python-pip
pip=pip
else
echo " This distribution is not supported by this installer.
manually install: wxpython python3-pip
and then copy tinge to /usr/local/bin and org.smolnet.tinge.desktop to /usr/share/applications/"
exit 1
fi
sudo ${pip} install -r ./requirements.txt
mkdir -p ~/.local/bin/ ~/.local/share/applications/
chmod +x ./main.py
cp ./main.py ~/.local/bin/tinge
sed "s/##USER##/${USER}/" tinge.desktop > ~/.local/share/applications/tinge.desktop
sudo cp -r ./tinge /usr/lib/python3.9/site-packages/
chmod +x ./scripts/tinge
cp ./scripts/tinge ~/.local/bin/
cp ./data/org.smolnet.tinge.desktop ~/.local/share/applications/
sudo cp -r ./src/tinge /usr/lib/python${python_version}/site-packages/
exit 0

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Where I keep the sources
deb_version=${1}
if [[ "x${deb_version}" == "x" ]]; then
deb_version=1
fi
BASEDIR=~/sources
SRCDIR="${BASEDIR}/tinge"
# Version of Tinge
VERSION=$(grep version= ${SRCDIR}/setup.py | awk -F '"' '{print $2}' )
# Change to source dir
olddir=$(pwd)
cd ${SRCDIR}
# Build deb
python3 setup.py --command-packages=stdeb.command bdist_deb #--debian-version ${deb_version}
cp deb_dist/* ${olddir}
rm -r ${SRCDIR}/deb_dist/ ${SRCDIR}/tinge-${VERSION}.tar.gz
cd ${olddir}

@ -2,5 +2,4 @@ toml==0.10.1
UPnPy==1.1.8
requests==2.25.1
wxPython~=4.0.7
simplejson~=3.17.2

@ -5,7 +5,7 @@ from typing import Union
import wx
import wx.lib.scrolledpanel as scrolled
from tinge import Tinge, HueBridge, HueGroup, HueLight, HueUtils, is_bridge
from tinge import HueBridge, HueGroup, HueLight, HueUtils, Tinge, is_bridge
class Hui(wx.Frame):
@ -14,7 +14,6 @@ class Hui(wx.Frame):
Args:
wx (Frame): Parent class
"""
def redraw(*args):
"""Decorator used for redrawing the widgets in the sizer
@ -43,7 +42,8 @@ class Hui(wx.Frame):
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: 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)
@ -66,9 +66,12 @@ class Hui(wx.Frame):
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)
lambda event, mbridge=bridge: self.goto_bridge(
mbridge),
btn)
else:
label = "{} {} ({})".format(self.m_unreachable_icon, str(bridge), "unreachable")
label = "{} {} ({})".format(self.m_unreachable_icon,
str(bridge), "unreachable")
btn: wx.Button = wx.Button(self.pnl, label=label)
self.sizer.Add(btn, 0, wx.EXPAND)
@ -76,8 +79,22 @@ class Hui(wx.Frame):
label = "Discover bridge"
btn: wx.Button = wx.Button(self.pnl, label=label)
self.sizer.Add(btn, 0, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event: self.discover_new_bridges(), btn)
self.Bind(wx.EVT_BUTTON, lambda event: self.discover_new_bridges(),
btn)
@redraw
def add_manage_bridge(self):
"""Add bridges to sizer, the entry point of the program
"""
self.SetTitle('Tinge - Manage Bridge')
label = "Delete Bridge"
btn: wx.Button = wx.Button(self.pnl, label=label)
self.sizer.Add(btn, 0, wx.EXPAND)
self.Bind(wx.EVT_BUTTON, lambda event: self.delete_bridge(),
btn)
back_btn: wx.Button = wx.Button(self.pnl, label="Go Back")
self.sizer.Add(back_btn, 0, wx.EXPAND)
self.Bind(wx.EVT_BUTTON, lambda event: self.add_groups(self.cur_bridge.get_groups()),
back_btn)
@redraw
def add_groups(self, groups: list[HueGroup]):
@ -91,10 +108,11 @@ class Hui(wx.Frame):
has_unattached: bool = len(self.cur_bridge.unattached_lights) > 0
self.sizer.Add(bridge_btn, 0, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event: self.add_bridges(), bridge_btn)
self.Bind(wx.EVT_BUTTON, lambda event: self.add_bridges(), bridge_btn)
if has_unattached:
group_label: wx.StaticText = wx.StaticText(self.pnl, label=" ⚯ Groups ⚯ ", style=wx.ALIGN_CENTER)
group_label: wx.StaticText = wx.StaticText(self.pnl,
label=" ⚯ Groups ⚯ ",
style=wx.ALIGN_CENTER)
self.sizer.Add(group_label, 0, wx.EXPAND)
for group in groups:
inner_sizer: wx.BoxSizer = wx.BoxSizer(orient=wx.HORIZONTAL)
@ -104,17 +122,25 @@ class Hui(wx.Frame):
icon = self.m_on_icon
toggle_btn: wx.Button = wx.Button(self.pnl, label=icon)
inner_sizer.Add(toggle_btn, 1, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mgroupid=groupid: self.toggle_group(mgroupid), toggle_btn)
self.Bind(
wx.EVT_BUTTON,
lambda event, mgroupid=groupid: self.toggle_group(mgroupid),
toggle_btn)
label: str = "{}".format(str(group))
group_btn: wx.Button = wx.Button(self.pnl, label=label, style=wx.BU_LEFT)
group_btn: wx.Button = wx.Button(self.pnl,
label=label,
style=wx.BU_LEFT)
inner_sizer.Add(group_btn, 4, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mgroupid=groupid: self.goto_group(mgroupid), group_btn)
self.Bind(
wx.EVT_BUTTON,
lambda event, mgroupid=groupid: self.goto_group(mgroupid),
group_btn)
self.sizer.Add(inner_sizer, 0, wx.EXPAND)
if has_unattached:
unattached_label: wx.StaticText = wx.StaticText(self.pnl, label=" ⚬ Unattached lights ⚬ ",
style=wx.ALIGN_CENTER)
unattached_label: wx.StaticText = wx.StaticText(
self.pnl,
label=" ⚬ Unattached lights ⚬ ",
style=wx.ALIGN_CENTER)
self.sizer.Add(unattached_label, 0, wx.EXPAND)
for light in self.cur_bridge.unattached_lights:
inner_sizer = wx.BoxSizer(orient=wx.HORIZONTAL)
@ -127,21 +153,32 @@ class Hui(wx.Frame):
toggle_btn: wx.Button = wx.Button(self.pnl, label=icon)
inner_sizer.Add(toggle_btn, 1, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mlightid=lightid: self.toggle_light_and_goto_group(mlightid, lights),
lambda event, mlightid=lightid: self.
toggle_light_and_goto_group(mlightid, lights),
toggle_btn)
label: str = "{}".format(light)
light_btn: wx.Button = wx.Button(self.pnl, label=label, style=wx.BU_LEFT)
light_btn: wx.Button = wx.Button(self.pnl,
label=label,
style=wx.BU_LEFT)
inner_sizer.Add(light_btn, 4, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mlightid=lightid: self.add_single_light(mlightid, True), light_btn)
lambda event, mlightid=lightid: self.
add_single_light(mlightid, True),
light_btn)
self.sizer.Add(inner_sizer, 0, wx.EXPAND)
bridge_mgm_btn: wx.Button = wx.Button(self.pnl, label="Manage Bridge")
self.sizer.Add(bridge_mgm_btn, 0, wx.EXPAND)
self.Bind(wx.EVT_BUTTON, lambda event: self.manage_bridge(), bridge_mgm_btn)
def add_manual_discovery_dialog(self) -> bool:
self.sizer.Clear(delete_windows=True)
found_any: bool = False
text_entry: wx.TextEntryDialog = wx.TextEntryDialog(self.pnl, "Manually enter IP address of bridge:",
caption="Auto discovery failure")
warn_label: wx.StaticText = wx.StaticText(self.pnl, label="Waiting for Button Press on Bridge")
text_entry: wx.TextEntryDialog = wx.TextEntryDialog(
self.pnl,
"Manually enter IP address of bridge:",
caption="Auto discovery failure")
warn_label: wx.StaticText = wx.StaticText(
self.pnl, label="Waiting for Button Press on Bridge")
if text_entry.ShowModal() == wx.ID_OK:
ipaddress: str = text_entry.GetValue()
if is_bridge(ipaddress):
@ -150,12 +187,14 @@ class Hui(wx.Frame):
user_or_error = HueUtils.connect(ipaddress)
while user_or_error.is_error():
user_or_error = HueUtils.connect(ipaddress)
self.m_tinge.append_bridge(HueBridge(ipaddress, user_or_error.get_user(), ipaddress))
self.m_tinge.append_bridge(
HueBridge(ipaddress, user_or_error.get_user(), ipaddress))
found_any = True
self.m_tinge.write_all_bridges_to_conf()
else:
label = "Supplied IP Address did not match a Bridge.",
failure_msg: wx.GenericMessageDialog = wx.GenericMessageDialog(self.pnl, label, caption="Try again!")
failure_msg: wx.GenericMessageDialog = wx.GenericMessageDialog(
self.pnl, label, caption="Try again!")
failure_msg.ShowModal()
return found_any
@ -170,7 +209,8 @@ class Hui(wx.Frame):
group_btn: wx.Button = wx.Button(self.pnl, label=str(self.cur_bridge))
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)
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()
@ -182,13 +222,18 @@ class Hui(wx.Frame):
toggle_btn: wx.Button = wx.Button(self.pnl, label=icon)
inner_sizer.Add(toggle_btn, 1, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mlightid=lightid: self.toggle_light_and_goto_group(mlightid, lights),
lambda event, mlightid=lightid: self.
toggle_light_and_goto_group(mlightid, lights),
toggle_btn)
label: str = "{}".format(light)
light_btn: wx.Button = wx.Button(self.pnl, label=label, style=wx.BU_LEFT)
light_btn: wx.Button = wx.Button(self.pnl,
label=label,
style=wx.BU_LEFT)
inner_sizer.Add(light_btn, 4, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mlightid=lightid: self.add_single_light(mlightid), light_btn)
lambda event, mlightid=lightid: self.add_single_light(
mlightid),
light_btn)
self.sizer.Add(inner_sizer, 0, wx.EXPAND)
@redraw
@ -203,16 +248,20 @@ class Hui(wx.Frame):
self.SetTitle("Tinge - {}".format(light))
is_on: bool = light.is_on()
if unattached:
group_btn: wx.Button = wx.Button(self.pnl, label=str(self.cur_bridge))
group_btn: wx.Button = wx.Button(self.pnl,
label=str(self.cur_bridge))
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)
self.Bind(
wx.EVT_BUTTON,
lambda event: self.add_groups(self.cur_bridge.get_groups()),
group_btn)
else:
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)
lambda event: self.goto_group(self.cur_group.get_id()),
group_btn)
# Toggle
icon: str = self.m_off_icon
if is_on:
@ -222,63 +271,102 @@ class Hui(wx.Frame):
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),
lambda event, mlightid=lightid: self.
toggle_light_and_goto_light(mlightid),
toggle_btn)
# Slider for brightness
if is_on:
if light.can_set_brightness():
b_label: wx.StaticText = wx.StaticText(self.pnl, label="Brightness")
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)
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,
lambda event: self.set_brightness(event, light.get_id()), b_slider)
self.Bind(
wx.EVT_SCROLL,
lambda event: self.set_brightness(event, light.get_id()),
b_slider)
# Slider for colortemp
if light.can_set_ct():
c_label: wx.StaticText = wx.StaticText(self.pnl, label="Color Temperature")
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)
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,
lambda event: self.set_colortemp(event, light.get_id()), c_slider)
self.Bind(
wx.EVT_SCROLL,
lambda event: self.set_colortemp(event, light.get_id()),
c_slider)
# Slider for hue
if light.can_set_hue():
d_label: wx.StaticText = wx.StaticText(self.pnl, label="Hue")
self.sizer.Add(d_label, 0, wx.EXPAND)
d_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_hue(), minValue=0, maxValue=65535)
d_slider: wx.Slider = wx.Slider(self.pnl,
value=light.get_hue(),
minValue=0,
maxValue=65535)
self.sizer.Add(d_slider, 0, wx.EXPAND)
self.Bind(wx.EVT_SCROLL,
lambda event: self.set_hue(event, light.get_id()), d_slider)
lambda event: self.set_hue(event, light.get_id()),
d_slider)
# Slider for saturation
if light.can_set_sat():
e_label: wx.StaticText = wx.StaticText(self.pnl, label="Saturation")
e_label: wx.StaticText = wx.StaticText(self.pnl,
label="Saturation")
self.sizer.Add(e_label, 0, wx.EXPAND)
e_slider: wx.Slider = wx.Slider(self.pnl, value=light.get_sat(), minValue=0, maxValue=254)
e_slider: wx.Slider = wx.Slider(self.pnl,
value=light.get_sat(),
minValue=0,
maxValue=254)
self.sizer.Add(e_slider, 0, wx.EXPAND)
self.Bind(wx.EVT_SCROLL,
lambda event: self.set_saturation(event, light.get_id()), e_slider)
self.Bind(
wx.EVT_SCROLL,
lambda event: self.set_saturation(event, light.get_id()),
e_slider)
rename_btn: wx.Button = wx.Button(self.pnl, label="Rename")
self.sizer.Add(rename_btn, 0, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mlightid=lightid: self.rename_light_and_goto_light(mlightid, unattached),
lambda event, mlightid=lightid: self.
rename_light_and_goto_light(mlightid, unattached),
rename_btn)
delete_btn: wx.Button = wx.Button(self.pnl, label="Delete")
self.sizer.Add(delete_btn, 0, wx.EXPAND)
self.Bind(wx.EVT_BUTTON,
lambda event, mlightid=lightid: self.delete_light_and_goto_group(mlightid),
lambda event, mlightid=lightid: self.
delete_light_and_goto_group(mlightid),
delete_btn)
def delete_bridge(self):
dlg: wx.MessageDialog = wx.MessageDialog(self.pnl,
"Delete " + self.cur_bridge.m_name + "?",
"Are you sure?",
style=wx.CANCEL | wx.CANCEL_DEFAULT | wx.OK)
dlg.SetOKCancelLabels("&Yes", "&Don't delete")
reply: int = dlg.ShowModal()
if reply == wx.ID_CANCEL:
self.add_groups(self.cur_bridge.get_groups())
else:
self.m_tinge.delete_bridge(self.cur_bridge)
self.add_bridges()
def delete_light_and_goto_group(self, lightid):
"""Combo call back for delete and goto group
Args:
lightid (int): The light id of the light to delete
"""
if self.get_ok_cancel_answer_from_modal("Are you sure you want to delete this light?"):
if self.get_ok_cancel_answer_from_modal(
"Are you sure you want to delete this light?"):
light: HueLight = self.cur_bridge.get_light_by_id(lightid)
light.delete()
self.cur_bridge.remove_light(light)
@ -299,7 +387,9 @@ class Hui(wx.Frame):
user_or_error = HueUtils.connect(bridge['ipaddress'])
while user_or_error.is_error():
user_or_error = HueUtils.connect(bridge['ipaddress'])
self.m_tinge.append_bridge(HueBridge(bridge['ipaddress'], user_or_error.get_user(), bridge['name']))
self.m_tinge.append_bridge(
HueBridge(bridge['ipaddress'], user_or_error.get_user(),
bridge['name']))
found_any = True
self.m_tinge.write_all_bridges_to_conf()
self.add_bridges()
@ -317,10 +407,16 @@ class Hui(wx.Frame):
Returns:
bool: The response from the user
"""
with wx.MessageDialog(self.pnl, message, style=wx.OK | wx.CANCEL | wx.CANCEL_DEFAULT) as dlg:
with wx.MessageDialog(self.pnl,
message,
style=wx.OK | wx.CANCEL
| wx.CANCEL_DEFAULT) as dlg:
return dlg.ShowModal() == wx.ID_OK
def get_text_answer_from_modal(self, message: str, cap: str, val: str = "") -> str:
def get_text_answer_from_modal(self,
message: str,
cap: str,
val: str = "") -> str:
"""Display a text entry and return the content
Args:
@ -331,7 +427,8 @@ class Hui(wx.Frame):
Returns:
str: The response from the user
"""
with wx.TextEntryDialog(self.pnl, message, caption=cap, value=val) as dlg:
with wx.TextEntryDialog(self.pnl, message, caption=cap,
value=val) as dlg:
dlg.ShowModal()
answer: str = dlg.GetValue()
return answer
@ -359,13 +456,20 @@ class Hui(wx.Frame):
self.cur_group = group
self.add_lights(group.get_lights())
def manage_bridge(self):
"""Call back for manage bridge button
"""
self.add_manage_bridge()
def rename_light_and_goto_light(self, lightid, unattached: bool = False):
"""Combo call back to rename a light and display that light again
Args:
lightid ([type]): The light id of the light to rename/display
"""
newname: str = self.get_text_answer_from_modal("Set new name", "New name:")
newname: str = self.get_text_answer_from_modal("Set new name",
"New name:")
if newname:
self.cur_bridge.get_light_by_id(lightid).rename(newname)
self.add_single_light(lightid, unattached)
@ -432,7 +536,8 @@ class Hui(wx.Frame):
self.cur_bridge.get_light_by_id(lightid).toggle()
self.add_single_light(lightid)
def toggle_light_and_goto_group(self, lightid: int, lights: list[HueLight]):
def toggle_light_and_goto_group(self, lightid: int,
lights: list[HueLight]):
"""Combo call back for toggle and goto group
Args:

@ -0,0 +1,28 @@
import setuptools
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setuptools.setup(
name="tinge",
version="0.0.3",
author="Micke Nordin",
author_email="hej@mic.ke",
data_files = [('share/applications', ['data/org.smolnet.tinge.desktop']),('share/icons/hicolor/scalable/apps',['data/org.smolnet.tinge.svg']),],
description="A GUI for Philips Hue lights.",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://code.smolnet.org/micke/tinge",
project_urls={
"Bug Tracker": "https://code.smolnet.org/micke/tinge/issues",
},
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GPL-3.0",
"Operating System :: OS Independent",
],
package_dir={"": "src"},
packages=setuptools.find_packages(where="src"),
python_requires=">=3.9",
scripts=["scripts/tinge"],
)

@ -12,13 +12,15 @@ class HueBridge:
"""This class represents a Hue Bridge
"""
def __init__(self, ipaddress: str, username: str, name: str = "", is_reachable: bool = True):
def __init__(self, ipaddress: str, username: str,
name: str = "", is_reachable: bool = True):
""" 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.
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
@ -58,8 +60,8 @@ class HueBridge:
return self.m_name
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
"""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
@ -70,36 +72,44 @@ class HueBridge:
if key != 'lastscan':
path: str = "{}/lights/{}".format(self.m_username, key)
response = make_request(self.m_ipaddress, path)
self.m_lights.append(HueLight(int(key), response.json(), self.get_ipaddress(), self.get_user()))
self.m_lights.append(HueLight(int(key), response.json(),
self.get_ipaddress(), self.get_user()))
return response.ok
def create_group(self, lights: list[HueLight], name: str, group_type: str = "LightGroup",
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".
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}
data: dict = {'lights': [], 'name': name,
'type': group_type, 'class': group_class}
for light in lights:
data['lights'].append(str(light.get_id()))
response = make_request(self.get_ipaddress(), path, method, json.dumps(data))
response = make_request(self.get_ipaddress(),
path, method, json.dumps(data))
r_json = response.json()
if 'success' in r_json.keys():
new_id = r_json['success']['id']
new_path = "{}/groups/{}".format(self.get_user(), new_id)
new_group = make_request(self.get_ipaddress(), new_path).json()
self.m_groups.append(HueGroup(int(new_id), lights, new_group, self.get_ipaddress(), self.get_user()))
self.m_groups.append(HueGroup(int(new_id), lights, new_group,
self.get_ipaddress(), self.get_user()))
return True
else:
return False
@ -117,7 +127,8 @@ class HueBridge:
lights: list[HueLight] = list()
for light in value['lights']:
lights.append(self.get_light_by_id(int(light)))
groups.append(HueGroup(int(key), lights, value, self.get_ipaddress(), self.get_user()))
groups.append(HueGroup(int(key), lights, value,
self.get_ipaddress(), self.get_user()))
return groups
def discover_lights(self) -> list[HueLight]:
@ -130,7 +141,8 @@ class HueBridge:
response = make_request(self.m_ipaddress, path)
lights: list[HueLight] = list()
for key, value in json.loads(response.text).items():
lights.append(HueLight(int(key), value, self.get_ipaddress(), self.get_user()))
lights.append(HueLight(int(key),
value, self.get_ipaddress(), self.get_user()))
return lights
def discover_new_lights(self, light_ids: Union[None, list[int]] = None) -> bool:
@ -211,7 +223,8 @@ class HueBridge:
if lightid != "lastscan":
print(lightid)
if not self.get_light_by_id(int(lightid)):
lightpath: str = "{}/lights/{}".format(self.m_username, int(lightid))
lightpath: str = "{}/lights/{}".format(
self.m_username, int(lightid))
lightresponse = make_request(self.m_ipaddress, lightpath)
newlights.append(
HueLight(int(lightid), lightresponse.json(), self.get_ipaddress(), self.get_user()))

@ -19,7 +19,8 @@ class HueGroup:
"""Constructor
Args:
data_slice (dict): The part of the data structure that concerns this Action
data_slice (dict): The part of the data structure that
concerns this Action
"""
keys = data_slice.keys()
self.m_on: bool = data_slice['on']
@ -52,14 +53,15 @@ class HueGroup:
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
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']
@ -199,7 +201,8 @@ class HueGroup:
Returns:
requests.Response: The API response
"""
path: str = "{}/groups/{}/action".format(self.m_parent_bridge_user, self.m_id)
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)
self.update_state()

@ -4,13 +4,11 @@ import http
import os
from typing import Union
import simplejson
import toml
from upnpy import UPnP
from .HueBridge import HueBridge
from .HueUtils import connect, is_valid_config, make_request, is_bridge
from .UserOrError import UserOrError
from .HueUtils import is_valid_config, make_request, is_bridge
class Tinge:
@ -26,16 +24,25 @@ class Tinge:
self.create_confdir()
self.read_bridges_from_file()
def append_bridge(self, bridge: HueBridge):
def append_bridge(self, bridge: HueBridge) -> None:
"""Append a bridge to the list
"""
self.m_bridges.append(bridge)
self.m_discovered.append(bridge.get_ipaddress())
def create_confdir(self):
def create_confdir(self) -> None:
"""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 delete_bridge(self, bridge: HueBridge) -> None:
"""Delete a bridge from the list
"""
self.m_bridges.remove(bridge)
self.m_discovered.remove(bridge.get_ipaddress())
self.write_all_bridges_to_conf()
def discover_new_bridges(self) -> Union[None, list[dict]]:
"""Use UPnP to discover bridges on the current network
"""
@ -56,8 +63,10 @@ class Tinge:
if (device.host not in self.m_discovered) and (device.host not in seen_ips):
seen_ips.append(device.host)
# Let's check if the device has the default name, if so we assume it's a hue bridge
if device.get_friendly_name().startswith("Philips hue"):
discovered = True
f_name = device.get_friendly_name()
if f_name:
if f_name.startswith("Philips hue"):
discovered = True
# If not we try to do a request against the api and see if we get an answer we can understand
else:
discovered = is_bridge(device.host)
@ -75,7 +84,7 @@ class Tinge:
"""
return self.m_bridges
def read_bridges_from_file(self):
def read_bridges_from_file(self) -> None:
"""Read config file and add back previously discovered bridges
"""
if is_valid_config(self.m_config):
@ -94,9 +103,11 @@ class Tinge:
self.m_bridges.append(bridge)
self.m_discovered.append(key)
def write_all_bridges_to_conf(self):
def write_all_bridges_to_conf(self) -> None:
"""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()))
if len(self.m_bridges) == 0:
configfile.truncate()

@ -0,0 +1,3 @@
[DEFAULT]
Depends3: python3-upnpy, python3-wxgtk4.0, python3-toml, python3-requests
Debian-Version: 2

@ -1,5 +0,0 @@
[Desktop Entry]
Type=Application
Name=Tinge
Exec=/home/##USER##/.local/bin/tinge
Icon=face-cool
Loading…
Cancel
Save