Skip to content

Commit f500705

Browse files
committed
feat: Custom presets
1 parent 1e1ec2e commit f500705

13 files changed

Lines changed: 241 additions & 28 deletions

File tree

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
11
import json
22
import os
3+
import logging
34

4-
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
5+
_LOGGER = logging.getLogger(__name__)
56

7+
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
68
MANIFEST = json.load(
79
open(os.path.join(BASE_PATH, 'manifest.json'))
810
)
911
VERSION = MANIFEST['version']
1012

13+
14+
15+
os.makedirs(os.path.join(BASE_PATH, 'userdata/custom/assets'), exist_ok=True)
16+
1117
PRESET_DATA = json.load(
1218
open(os.path.join(BASE_PATH, 'presets.json'))
1319
)
1420

21+
custom_presets_path = os.path.join(BASE_PATH, 'userdata/custom/presets.json')
22+
CUSTOM_PRESETS = None
23+
if os.path.exists(custom_presets_path):
24+
try:
25+
with open(custom_presets_path, 'r') as file:
26+
CUSTOM_PRESETS = json.load(file)
1527

28+
print("Custom presets loaded successfully.")
29+
except json.JSONDecodeError as e:
30+
_LOGGER.error(f"Error loading custom presets: {e}")
31+
else:
32+
_LOGGER.info(f"No custom presets file found at {custom_presets_path}")
1633

17-
34+
if CUSTOM_PRESETS is not None:
35+
PRESET_DATA.get("presets", []).extend([{**d, 'custom': True} for d in CUSTOM_PRESETS.get("presets", [])])
36+
PRESET_DATA.get("categories", []).extend([{**d, 'custom': True} for d in CUSTOM_PRESETS.get("categories", [])])

custom_components/scene_presets/frontend/scene_presets_panel.js

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

custom_components/scene_presets/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
"@hypfer"
66
],
77
"config_flow": true,
8-
"dependencies": [],
8+
"after_dependencies": ["http"],
99
"documentation": "https://github.com/Hypfer/hass-scene_presets",
1010
"integration_type": "service",
1111
"iot_class": "calculated",
1212
"issue_tracker": "https://github.com/Hypfer/hass-scene_presets/issues",
1313
"requirements": [],
14-
"version": "1.0.0"
14+
"version": "1.1.0"
1515
}

custom_components/scene_presets/presets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
},
5959
{
6060
"id": "6e102277-814f-47c7-a054-f4a31cae30bf",
61-
"name": "Custom palettes"
61+
"name": "Misc Additions"
6262
}
6363
],
6464
"presets": [

custom_components/scene_presets/view.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
from .const import NAME, DOMAIN, PANEL_URL
22
from .file_utils import VERSION, PRESET_DATA, BASE_PATH
3+
from homeassistant.components.http import HomeAssistantView
34

5+
class ScenePresetDataView(HomeAssistantView):
6+
url = f'/assets/{DOMAIN}/scene_presets.json'
7+
name = f'assets:{DOMAIN}:preset_data'
8+
requires_auth = False
9+
10+
async def get(self, request):
11+
return self.json(
12+
result=PRESET_DATA,
13+
)
414

515
async def async_setup_view(hass):
616
hass.http.register_static_path(
717
PANEL_URL,
818
hass.config.path(f'{BASE_PATH}/frontend/scene_presets_panel.js'),
919
)
10-
11-
hass.http.register_static_path(
12-
f'/assets/{DOMAIN}/scene_presets.json',
13-
hass.config.path(f"{BASE_PATH}/presets.json"),
14-
)
20+
21+
hass.http.register_view(ScenePresetDataView)
1522

1623
await bind_preset_images(hass)
1724

@@ -34,9 +41,14 @@ async def async_setup_view(hass):
3441
async def bind_preset_images(hass):
3542
for preset in PRESET_DATA.get("presets", []):
3643
img_filename = preset.get("img")
44+
is_custom = preset.get("custom")
3745

3846
if img_filename is not None:
47+
path = f"{BASE_PATH}/assets/{img_filename}"
48+
if is_custom is not None and is_custom:
49+
path = f"{BASE_PATH}/userdata/custom/assets/{img_filename}"
50+
3951
hass.http.register_static_path(
4052
f'/assets/{DOMAIN}/{img_filename}',
41-
hass.config.path(f"{BASE_PATH}/assets/{img_filename}"),
53+
hass.config.path(path),
4254
)

docs/Custom Presets.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Custom presets
2+
3+
Starting with v1.1.0, this integration also allows you to have your own custom presets.
4+
5+
Be advised though that this feature will not hold your hand. It is intended to be used by developers.
6+
7+
## How
8+
9+
On startup, the custom_component will create folder named `userdata/custom` inside `custom_components/scene_presets`
10+
of your home assistant instance. By default, this folder will be empty apart from another folder named `assets`.
11+
12+
To add custom presets, you create a `presets.json` in that folder and add your custom images to that `assets` folder.
13+
This essentially mirrors the structure of the included scenes. The JSON Schema is the same.
14+
Everything in the custom presets will simply be added at the end of the lists of categories and presets.
15+
16+
Please note that there is no validation in place that would prevent you from creating ID conflicts nor is there any
17+
validation ensuring that the schema of the custom data is correct. Just don't provide any incorrect data.
18+
19+
If I understood the HACS documentation correctly, this folder should survive component updates.<br/>
20+
For now though, I'd recommend making backups just to be sure.
21+
22+
## Example
23+
24+
The best way to explain this feature is to give an example.
25+
26+
First the directory structure:
27+
28+
```
29+
user@foo:/homeassistant/custom_components/scene_presets# tree userdata/
30+
userdata/
31+
└── custom
32+
├── assets
33+
│ └── 1d2ef59e-8f29-4d58-a437-c0b03d90ce8a.jpeg
34+
└── presets.json
35+
36+
3 directories, 2 files
37+
```
38+
39+
And here's the content of the `presets.json`:
40+
41+
```
42+
{
43+
"categories": [
44+
{
45+
"name": "Color Temperatures",
46+
"id": "e0c17262-f84b-4943-bdd5-fcd24c574f24"
47+
}
48+
],
49+
"presets": [
50+
{
51+
"id": "1d2ef59e-8f29-4d58-a437-c0b03d90ce8a",
52+
"categoryId": "e0c17262-f84b-4943-bdd5-fcd24c574f24",
53+
"name": "1800 Kelvin",
54+
"bri": 76,
55+
"lights": [
56+
{
57+
"x": 0.6264,
58+
"y": 0.3632
59+
}
60+
]
61+
}
62+
]
63+
}
64+
```
65+
66+
In there you can see a few things:
67+
68+
- We've added a new category named "Color Temperatures"
69+
- We've added a single preset with the "Color Temperatures" categoryId
70+
71+
=> every preset needs to be part of a category <br/>
72+
=> custom presets can be part of stock categories <br/>
73+
- The new "1800 Kelvin" preset has a single light
74+
75+
=> There is no limit to how many lights a preset can have <br/>
76+
=> If you apply a preset to a group with more lights, some options will be repeated <br/>
77+
=> Colors have to be specified as X/Y
78+
79+
After adding that example JSON and restarting Home Assistant, it looks like this:
80+
81+
![ex1.png](img/ex1.png)
82+
83+
And as you can see, we forgot to install Counter-Strike: Source.
84+
85+
To fix that, we need to change the preset to include an `img` key like this:
86+
87+
```
88+
{
89+
"id": "1d2ef59e-8f29-4d58-a437-c0b03d90ce8a",
90+
"categoryId": "e0c17262-f84b-4943-bdd5-fcd24c574f24",
91+
"name": "1800 Kelvin",
92+
"img": "1d2ef59e-8f29-4d58-a437-c0b03d90ce8a.jpeg"
93+
"bri": 76,
94+
"lights": [
95+
{
96+
"x": 0.6264,
97+
"y": 0.3632
98+
}
99+
]
100+
}
101+
```
102+
103+
This will now point to `userdata/custom/assets/1d2ef59e-8f29-4d58-a437-c0b03d90ce8a.jpeg`.<br/>
104+
Make sure to place the desired image there.
105+
106+
And that's it. When in doubt, take a look at the `presets.json` and `assets` included with the component.<br/>
107+
As said, they are the same format and structure.
108+
109+
## Misc
110+
111+
To convert RGB colors to X/Y, you can use this js snippet using the `cie-rgb-color-converter` npm library:
112+
```
113+
const colorConverter = require("cie-rgb-color-converter");
114+
115+
const xyValue = colorConverter.rgbToXy(255, 126, 0);
116+
xyValue.x = parseFloat(xyValue.x.toFixed(4));
117+
xyValue.y = parseFloat(xyValue.y.toFixed(4));
118+
119+
console.log(JSON.stringify(xyValue, null, 2))
120+
```

docs/Smart Shuffle.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# What is Smart Shuffle?
2+
3+
With hue (and probably other vendors as well), when you apply a random color with a transition (especially long ones), it can happen that the light turns to white and brightly illuminates the otherwise rather dark and moody room.
4+
5+
This happens, because the transition feature basically just moves from one coordinate in the CIE1931 color space to another. So if you for example do a transition from turquoise to red, that transition would move right through the area where white is and thus unexpectedly illuminate the whole room.
6+
7+
![space1.jpeg](img/space1.jpeg)
8+
9+
Smart shuffle solves that issue by taking the current color of each light and then filtering the set of colors in the preset to only leave those that would result in a transition that doesn't move through white.
10+
11+
It works by:
12+
1. taking the coordinates of a point in the middle of the white area
13+
2. drawing a line from the current color coordinates
14+
3. drawing a line from the possible next color coordinates
15+
4. looking at the angle between those two lines
16+
a. If the angle is > 150°, the transition will be considered as moving through white
17+
b. If the angle is < 2°, the possible next color will be considered the same color
18+
5. If any of those two criteria matches, the color will not be selected as the next random color for that light.
19+
6. In the end, it selects a random option out of that filtered set
20+
7. If the filtered set is empty, it just picks a random color from the whole preset
21+
22+
![space2.jpeg](img/space2.jpeg)
23+
24+
The exact angles (150, 2) are subject to change. I just picked them mostly at random.
25+
26+
Anyway, while this does work great with the right set of colors, you can create a set where you accidentally partition your lights, causing them to just cycle through the same subset of colors of the whole preset all the time.
27+
28+
I've noticed that happening with the CGA preset I've added recently.
29+
Because of that, it's now a toggle that can be disabled if the algorithm doesn't work well with the preset.
30+
31+
While adding that, I've also made the regular shuffle a bit better for this feature then before. Instead of just pulling n colors (with n being the count of lights) out of the set of the preset colors at random (and thus giving no guarantees that they will be evenly distributed on a single apply) the code now
32+
1. Takes the set of all colors in the preset
33+
2. Creates enough shuffled duplicates of that set so that the length of those combined is longer than n
34+
3. Picks the color for each light based on its index in that new set
35+
36+
This ensures that if you have 6 lights and apply a preset with 5 colors, you will get all 5 colors on 5 lamps + 1 repeated one for the 6th lamp.
37+
Previously, you could by random chance get 6 lights in the same color.
38+
Unlikely but possible.
39+
40+
At least that's how it works at the time of writing (2023-12-09). It might change in the future if I come up with different ideas or notice other issues

docs/img/ex1.png

201 KB
Loading

docs/img/space1.jpeg

72.1 KB
Loading

docs/img/space2.jpeg

71.2 KB
Loading

0 commit comments

Comments
 (0)