everlights 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +172 -0
- data/exe/everlights +192 -0
- data/lib/everlights/sync.rb +433 -0
- data/lib/everlights.rb +256 -0
- data/skills/everlights/SKILL.md +73 -0
- metadata +51 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5470906a45eb2946bbf14cdba11dc89917ad92244e2b224f655513ff944ee66a
|
|
4
|
+
data.tar.gz: 1c6f03b3cfa021d8c16692fb555d39a15416c54a82cda3058fe15faae585049b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ef7f03a9d96aa9fbf041cc566bc4e14c7960a9c070630a5633353f55a4acb111aad1092e17abe928673f4225a21c50b75eb0a5195113f30f8d39291b1cbc58c4
|
|
7
|
+
data.tar.gz: 815c442cc1d22a33676bec89a25578b70463c10be529ce77e3699c1c355db5b11a567f2ebca9b76a092bae68b65fbd5b23deee80bfa25b087cdb7f79706209ee
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ricky Chilcott
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# everlights
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<picture>
|
|
5
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/everlights-dark.png">
|
|
6
|
+
<img src="assets/everlights-light.png" alt="EverLights" width="300">
|
|
7
|
+
</picture>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
A Ruby client and CLI for the EverLights v3 Smart Lights Bridge. It talks to the bridge directly on your local network: no cloud, no account, and no dependencies outside Ruby's standard library.
|
|
11
|
+
|
|
12
|
+
Use it to show colors now, manage saved sequences, and put shows on the calendar weeks or months ahead. The bridge stores scheduled shows itself, so they run with no computer on.
|
|
13
|
+
|
|
14
|
+
> Unofficial. Not affiliated with or endorsed by EverLights. The EverLights name and logo belong to their owner.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
gem install everlights
|
|
20
|
+
export EVERLIGHTS_HOST=192.168.1.50 # your bridge's IP (EverLights app → device information)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Ruby 3.1+.
|
|
24
|
+
|
|
25
|
+
## CLI
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
everlights bridge # firmware, network, cloud, zone health
|
|
29
|
+
everlights show # what's playing, e.g. "Halloween Fade (Halloween show)"
|
|
30
|
+
|
|
31
|
+
everlights play "Hallows Eve" # a saved sequence, now
|
|
32
|
+
everlights set FF0000 00FF00 --effect twinkle=100 # ad-hoc colors, now
|
|
33
|
+
everlights off # lights dark
|
|
34
|
+
everlights clear # hand control back to the calendar
|
|
35
|
+
|
|
36
|
+
everlights sequence list fall # * marks sequences you created
|
|
37
|
+
everlights sequence add "Candy Cane" FF0000 FFFFFF --effect chase=60
|
|
38
|
+
everlights sequence update "Candy Cane" FF0000 00FF00 FFFFFF # replace colors/effects
|
|
39
|
+
everlights sequence remove "Candy Cane"
|
|
40
|
+
|
|
41
|
+
everlights calendar list --limit 30 # upcoming shows (default 365 days)
|
|
42
|
+
everlights calendar add 2026-12-01 Christmas "Candy Cane:30,Christmas" --until 2027-01-01
|
|
43
|
+
everlights calendar add 2026-12-31 "New Year" "New Year's" --start 20:00 --end 01:00
|
|
44
|
+
everlights calendar remove 2026-12-24 Christmas # one night
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Calendar defaults:** a show starts at dusk and ends at 23:00. Each sequence plays for 20 seconds unless you write `name:seconds`.
|
|
48
|
+
- **Times** use your machine's local time zone. An end time earlier than the start means the next morning. `--until` repeats the show nightly and stays correct across DST changes.
|
|
49
|
+
- **Effects:** `bars blink chase fade gradient lightning melt pingPong random reverseChase strobe twinkle wipe`, each with a strength from 0 to 255.
|
|
50
|
+
- **JSON:** add `--json` to list commands and `bridge`.
|
|
51
|
+
- **Now commands** (`set`, `play`, `off`): during a running show they only last until its next sequence change.
|
|
52
|
+
- **Bridge upkeep:** `everlights bridge time [TIME]` sets the clock (default now; the bridge normally syncs via NTP). `everlights bridge reboot --yes` restarts it.
|
|
53
|
+
|
|
54
|
+
## Sync: your year of shows from one file
|
|
55
|
+
|
|
56
|
+
Describe your own sequences and a schedule once. `everlights sync` then builds them on the bridge for the months ahead:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
cp config/everlights.example.yml config/everlights.yml # gitignored; edit the IP, location and schedule
|
|
60
|
+
everlights sync --dry-run # print the plan
|
|
61
|
+
everlights sync # apply it
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
bridge: {host: 192.168.1.50}
|
|
66
|
+
location: {lat: 40.0, lon: -83.0} # dusk = sunset, calculated per night
|
|
67
|
+
horizon_days: 120
|
|
68
|
+
sequences:
|
|
69
|
+
My Birthday: {colors: [FF1E8C, FFC400, 00A8FF], effects: {chase: 60, twinkle: 180}}
|
|
70
|
+
schedule: # higher in the list wins a shared night
|
|
71
|
+
- {name: Christmas Day, on: Dec 25, sequences: [Christmas, "Candy Cane Lane:30"], end: "01:00"}
|
|
72
|
+
- {name: Christmas Season, from: Dec 1, to: Jan 1, sequences: [Christmas, Winter]}
|
|
73
|
+
- {name: Thanksgiving, on: 4th Thursday of November, sequences: [Thanksgiving]}
|
|
74
|
+
- {name: Easter, on: Easter, sequences: [Easter]}
|
|
75
|
+
- {name: Fall Weekends, every: [Friday, Saturday], from: Sep 15, to: Nov 20, start: dusk+15, sequences: [Fall Wave]}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- **When a rule applies:**
|
|
79
|
+
- `on:` takes a date (`2027-03-14`), a yearly date (`Dec 25`), `Nth Weekday of Month`, `last Monday of May`, or `Easter`.
|
|
80
|
+
- `from:`/`to:` repeats nightly, every year, and can cross New Year.
|
|
81
|
+
- `every:` takes weekdays, optionally limited by `from:`/`to:`.
|
|
82
|
+
- **Times:** `start:` is `dusk`, `dusk+15` or `"18:30"`; `end:` defaults to `"23:00"`. An end earlier than the start means the next morning.
|
|
83
|
+
- **Future only:** sync adds only nights that start more than 15 minutes from now. Each rule becomes one calendar entry, and re-running sync rolls the window forward.
|
|
84
|
+
- **Leaves your app-made shows alone:** sync only changes or deletes what it created, tracked in `config/.sync-state.yml`. It skips nights an app-made show already covers, and refuses names that clash with built-ins or app-made items. If the state file is lost, `--adopt` takes back items whose names match the config.
|
|
85
|
+
- **All or nothing:** every config problem is reported before anything is written.
|
|
86
|
+
- **Config location:** `--config` or `$EVERLIGHTS_CONFIG`. The config's `bridge.host` is used by every command unless `--host` or `$EVERLIGHTS_HOST` is set.
|
|
87
|
+
- **Time zone:** set `timezone:` in the config, or it uses your machine's.
|
|
88
|
+
|
|
89
|
+
To keep the calendar topped up, run it on a schedule, for example monthly from cron:
|
|
90
|
+
`0 9 1 * * cd ~/everlights && everlights sync >> config/sync.log 2>&1`
|
|
91
|
+
|
|
92
|
+
## Ruby
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
require "everlights"
|
|
96
|
+
|
|
97
|
+
el = Everlights::Client.new("192.168.1.50") # or ENV["EVERLIGHTS_HOST"]
|
|
98
|
+
|
|
99
|
+
el.play "Hallows Eve"
|
|
100
|
+
el.set "FF0000", "00FF00", effects: {twinkle: 100}
|
|
101
|
+
el.off
|
|
102
|
+
el.clear
|
|
103
|
+
el.status # bridge info hash; el.set_time, el.reboot
|
|
104
|
+
el.now_playing # {zone => {name:, show:, off:, pattern:, effects:}}
|
|
105
|
+
|
|
106
|
+
el.sequences.list("fall")
|
|
107
|
+
el.sequences.add "Candy Cane", "FF0000", "FFFFFF", effects: {chase: 60}
|
|
108
|
+
el.sequences.update "Candy Cane", "FF0000", "00FF00"
|
|
109
|
+
el.sequences.remove "Candy Cane"
|
|
110
|
+
|
|
111
|
+
el.calendar.list(limit: 30) # => [{date:, start:, finish:, dusk:, name:, event_id:, sequences: [[name, secs]]}]
|
|
112
|
+
el.calendar.add "2026-12-01", "Christmas", {"Candy Cane" => 30, "Christmas" => 20}, through: "2027-01-01"
|
|
113
|
+
el.calendar.add "2026-12-31", "New Year", ["New Year's"], start: "20:00", finish: "01:00"
|
|
114
|
+
el.calendar.remove "2026-12-24", "Christmas"
|
|
115
|
+
|
|
116
|
+
config = Everlights::Sync.load_config("config/everlights.yml")
|
|
117
|
+
plan = Everlights::Sync.new(el, config, state_path: "config/.sync-state.yml").plan
|
|
118
|
+
puts plan
|
|
119
|
+
plan.apply!
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`Client` also exposes the raw endpoints: `status`, `zones`, `program`, `set_program`, `sequence_list`, `create_event`, `update_event` and so on.
|
|
123
|
+
|
|
124
|
+
## AI agent skill
|
|
125
|
+
|
|
126
|
+
`skills/everlights/SKILL.md` teaches an AI agent, such as Claude Code, to drive the CLI: what to check first, confirming before any change to the lights, and recipes. To install it:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
ln -s "$(pwd)/skills/everlights" ~/.claude/skills/everlights
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Protocol
|
|
133
|
+
|
|
134
|
+
The bridge (an ESP32) serves plain JSON over HTTP on port 80, with no authentication:
|
|
135
|
+
|
|
136
|
+
| Endpoint | |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `GET /v1` | bridge status, including zones |
|
|
139
|
+
| `GET /v1/zones` | zones (pixel runs) |
|
|
140
|
+
| `GET POST DELETE /v1/zones/{serial}/sequence` | what a zone shows right now |
|
|
141
|
+
| `GET POST /v1/sequences`, `PUT DELETE /v1/sequences/{id}` | saved sequences |
|
|
142
|
+
| `GET POST /v1/events`, `PUT DELETE /v1/events/{id}` | calendar |
|
|
143
|
+
| `PUT /v1/time`, `GET /v1/reboot` | clock; reboot (note: a GET) |
|
|
144
|
+
|
|
145
|
+
Verified against bridge firmware B4042, radio R3052 and zone firmware N3063. Firmware updates arrive over the air from EverLights' cloud; there is no local update endpoint.
|
|
146
|
+
|
|
147
|
+
Not yet confirmed:
|
|
148
|
+
- What each effect's 0–255 value does.
|
|
149
|
+
- How the bridge handles overlapping calendar entries.
|
|
150
|
+
- **Dusk:** the bridge stores the 17:00 placeholder start as given; the EverLights app writes the actual dusk time. It's unclear whether the bridge honors `StartAtDusk` on its own, so a "dusk" show may start at 17:00.
|
|
151
|
+
|
|
152
|
+
Observed on the bridge (`ruby -Ilib test/live_check.rb` repeats the round trip):
|
|
153
|
+
- Sequence durations are seconds.
|
|
154
|
+
- Sequences you add are filed in the app's `Personal/` group and appear in the EverLights app under Personal.
|
|
155
|
+
- During a show, `set`/`play` only lasts until the show's next sequence change. `clear` briefly blanks the zone, then the show resumes.
|
|
156
|
+
- `GET /v1/zones/{serial}/sequence` reports what's on the lights, including what a running show is playing.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
ruby -Ilib -e 'Dir["test/*_test.rb"].each { require "./#{_1}" }'
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The tests run against a fake bridge, so no hardware is needed.
|
|
165
|
+
|
|
166
|
+
## Credits
|
|
167
|
+
|
|
168
|
+
The protocol work builds on [weswitt/everlights3](https://github.com/weswitt/everlights3), whose OpenAPI spec mapped the endpoints, and [theOrakle/everlightsv3](https://github.com/theOrakle/everlightsv3), which is also the source of the logo images. There is also a Home Assistant [core integration](https://www.home-assistant.io/integrations/everlights) for the older v2 bridge.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
data/exe/everlights
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
require_relative "../lib/everlights"
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
USAGE = <<~TXT
|
|
6
|
+
Usage: everlights COMMAND [args] [options]
|
|
7
|
+
|
|
8
|
+
Bridge:
|
|
9
|
+
bridge firmware, network, cloud, zone health
|
|
10
|
+
bridge time [TIME] set the bridge clock (default now)
|
|
11
|
+
bridge reboot --yes restart the bridge
|
|
12
|
+
zones raw zone data (JSON)
|
|
13
|
+
|
|
14
|
+
Now (a running show takes back over at its next sequence change):
|
|
15
|
+
show what each zone is playing
|
|
16
|
+
set COLOR... [--effect E[=N]] show colors now
|
|
17
|
+
play SEQUENCE show a saved sequence now
|
|
18
|
+
off lights dark
|
|
19
|
+
clear hand control back to the calendar
|
|
20
|
+
|
|
21
|
+
Sequences:
|
|
22
|
+
sequence list [FILTER]
|
|
23
|
+
sequence add NAME COLOR... [--effect E[=N]]...
|
|
24
|
+
sequence update NAME COLOR... [--effect E[=N]]... replace colors and effects
|
|
25
|
+
sequence remove NAME
|
|
26
|
+
|
|
27
|
+
Calendar:
|
|
28
|
+
calendar list [--limit DAYS] upcoming shows, default next 365 days
|
|
29
|
+
calendar add DATE NAME SEQ[:SECS],... [--start HH:MM] [--end HH:MM] [--until DATE]
|
|
30
|
+
default dusk to 23:00, 20s per sequence
|
|
31
|
+
calendar remove DATE NAME
|
|
32
|
+
|
|
33
|
+
Sync (config/everlights.yml: sequences + schedule, see config/everlights.example.yml):
|
|
34
|
+
sync [--dry-run] [--adopt] build sequences and the calendar ahead; prints the plan
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
TXT
|
|
38
|
+
|
|
39
|
+
opts = {effects: {}, limit: 365}
|
|
40
|
+
parser = OptionParser.new do |o|
|
|
41
|
+
o.banner = USAGE
|
|
42
|
+
o.on("--host HOST", "bridge IP (default $EVERLIGHTS_HOST, then the config's bridge.host)") { opts[:host] = _1 }
|
|
43
|
+
o.on("--config PATH", "config file (default $EVERLIGHTS_CONFIG, then config/everlights.yml)") { opts[:config] = _1 }
|
|
44
|
+
o.on("--zone SERIAL", "limit set/play/off/clear/show to a zone (repeatable)") { (opts[:zones] ||= []) << _1 }
|
|
45
|
+
o.on("--effect E[=N]", "N 0-255 (default 128): #{Everlights::EFFECTS.join(" ")}") do |e|
|
|
46
|
+
type, value = e.split("=")
|
|
47
|
+
opts[:effects][type] = value
|
|
48
|
+
end
|
|
49
|
+
o.on("--limit DAYS", Integer, "calendar list horizon") { opts[:limit] = _1 }
|
|
50
|
+
o.on("--start HH:MM", "fixed start instead of dusk") { opts[:start] = _1 }
|
|
51
|
+
o.on("--end HH:MM", "default 23:00; earlier than start = next morning") { opts[:end] = _1 }
|
|
52
|
+
o.on("--until DATE", "repeat nightly through DATE") { opts[:until] = _1 }
|
|
53
|
+
o.on("--json", "JSON output for lists, show and bridge") { opts[:json] = true }
|
|
54
|
+
o.on("--yes", "confirm bridge reboot") { opts[:yes] = true }
|
|
55
|
+
o.on("--dry-run", "sync: print the plan only") { opts[:dry_run] = true }
|
|
56
|
+
o.on("--adopt", "sync: take over bridge items whose names match the config") { opts[:adopt] = true }
|
|
57
|
+
end
|
|
58
|
+
parser.parse!
|
|
59
|
+
abort parser.help if ARGV.empty?
|
|
60
|
+
|
|
61
|
+
def json(obj) = puts(JSON.pretty_generate(obj))
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
config_path = opts[:config] || ENV["EVERLIGHTS_CONFIG"] || "config/everlights.yml"
|
|
65
|
+
config = if File.exist?(config_path) then Everlights::Sync.load_config(config_path)
|
|
66
|
+
elsif opts[:config] || ENV["EVERLIGHTS_CONFIG"] then raise Everlights::Error, "no config at #{config_path}"
|
|
67
|
+
end
|
|
68
|
+
ENV["TZ"] = config["timezone"] if config&.dig("timezone")
|
|
69
|
+
el = Everlights::Client.new(opts[:host] || ENV["EVERLIGHTS_HOST"] || config&.dig("bridge", "host"))
|
|
70
|
+
zones = opts[:zones]
|
|
71
|
+
cmd = ARGV.shift
|
|
72
|
+
cmd = "#{cmd} #{ARGV.shift}" if %w[sequence calendar].include?(cmd) ||
|
|
73
|
+
(cmd == "bridge" && %w[time reboot].include?(ARGV.first))
|
|
74
|
+
arg = ARGV.join(" ")
|
|
75
|
+
|
|
76
|
+
case cmd
|
|
77
|
+
when "bridge"
|
|
78
|
+
b = el.status
|
|
79
|
+
if opts[:json] then json(b)
|
|
80
|
+
else
|
|
81
|
+
at = ->(v) { v ? Time.parse(v).localtime.strftime("%Y-%m-%d %H:%M:%S") : "?" }
|
|
82
|
+
net = b["network"] || {}
|
|
83
|
+
puts "Bridge #{b["serial"]} firmware #{b["firmwareVersion"]}, radio #{b["radioFirmwareVersion"]}"
|
|
84
|
+
puts " network #{net["interface"] || "?"} #{b["localIp"]}, ntp #{net["ntp"] ? "ok" : "off"}"
|
|
85
|
+
puts " cloud #{b["serverReachable"] ? "reachable" : "UNREACHABLE"}, last check-in #{at.(b["lastServerCheckin"])}"
|
|
86
|
+
puts " reboots #{b["rebootCount"]}"
|
|
87
|
+
b["zones"].each do |z|
|
|
88
|
+
puts "Zone #{z["serial"]} firmware #{z["firmwareVersion"]}, #{z["configuredLength"]} pixels #{z["colorOrder"]}, #{z["active"] ? "active" : "INACTIVE"}"
|
|
89
|
+
puts " signal rssi #{z["rssi"]}, snr #{z["snr"]}"
|
|
90
|
+
puts " sensors temperature #{z["temperature"]}, voltage #{z["voltage"]}, current #{z["current"]} (raw, units unknown)"
|
|
91
|
+
puts " last request #{at.(z["lastRequestDate"])}, response #{at.(z["lastResponseDate"])}, reboots #{z["rebootCount"]}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
when "bridge time"
|
|
95
|
+
time = ARGV.empty? ? Time.now : Time.parse(arg)
|
|
96
|
+
el.set_time(time)
|
|
97
|
+
puts "bridge clock set to #{time}"
|
|
98
|
+
when "bridge reboot"
|
|
99
|
+
abort "rebooting interrupts the lights; rerun with --yes" unless opts[:yes]
|
|
100
|
+
el.reboot
|
|
101
|
+
puts "rebooting bridge"
|
|
102
|
+
when "zones" then json el.zones
|
|
103
|
+
when "show"
|
|
104
|
+
playing = el.now_playing(zones: zones)
|
|
105
|
+
if opts[:json] then json(playing)
|
|
106
|
+
else
|
|
107
|
+
playing.each do |zone, p|
|
|
108
|
+
what = if p[:off] then "off"
|
|
109
|
+
elsif p[:name] then "#{p[:name]}#{" (#{p[:show]} show)" if p[:show]}"
|
|
110
|
+
else "custom colors #{p[:pattern].join(" ")}"
|
|
111
|
+
end
|
|
112
|
+
fx = p[:effects].map { "#{_1["effectType"]}=#{_1["value"]}" }.join(" ")
|
|
113
|
+
puts "#{zone} #{what}#{" #{fx}" unless fx.empty?}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
when "set"
|
|
117
|
+
abort "set needs colors" if ARGV.empty?
|
|
118
|
+
el.set(*ARGV, effects: opts[:effects], zones: zones)
|
|
119
|
+
puts "showing #{arg}"
|
|
120
|
+
when "play" then puts "playing #{el.play(arg, zones: zones)["alias"]}"
|
|
121
|
+
when "off"
|
|
122
|
+
el.off(zones: zones)
|
|
123
|
+
puts "off"
|
|
124
|
+
when "clear"
|
|
125
|
+
el.clear(zones: zones)
|
|
126
|
+
puts "cleared, calendar in control"
|
|
127
|
+
|
|
128
|
+
when "sequence list"
|
|
129
|
+
seqs = el.sequences.list(arg)
|
|
130
|
+
if opts[:json] then json(seqs)
|
|
131
|
+
else
|
|
132
|
+
seqs.each do |s|
|
|
133
|
+
mark = s["id"].start_with?(Everlights::BUILTIN_PREFIX) ? " " : "*"
|
|
134
|
+
fx = s["effects"].map { "#{_1["effectType"]}=#{_1["value"]}" }.join(" ")
|
|
135
|
+
puts "#{mark} #{s["alias"].ljust(28)} #{s["pattern"].size.to_s.rjust(3)} color#{"s" unless s["pattern"].size == 1}#{" " if s["pattern"].size == 1} #{fx}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
when "sequence add"
|
|
139
|
+
name, *colors = ARGV
|
|
140
|
+
abort "sequence add NAME COLOR..." if colors.empty?
|
|
141
|
+
el.sequences.add(name, *colors, effects: opts[:effects])
|
|
142
|
+
puts "added sequence #{name}"
|
|
143
|
+
when "sequence update"
|
|
144
|
+
name, *colors = ARGV
|
|
145
|
+
abort "sequence update NAME COLOR..." if colors.empty?
|
|
146
|
+
el.sequences.update(name, *colors, effects: opts[:effects])
|
|
147
|
+
puts "updated sequence #{name}"
|
|
148
|
+
when "sequence remove"
|
|
149
|
+
el.sequences.remove(arg)
|
|
150
|
+
puts "removed sequence #{arg}"
|
|
151
|
+
|
|
152
|
+
when "calendar list"
|
|
153
|
+
shows = el.calendar.list(limit: opts[:limit])
|
|
154
|
+
if opts[:json]
|
|
155
|
+
json(shows.map { |s| s.merge(date: s[:date].iso8601, start: s[:start].iso8601, finish: s[:finish].iso8601) })
|
|
156
|
+
elsif shows.empty?
|
|
157
|
+
puts "nothing scheduled in the next #{opts[:limit]} days"
|
|
158
|
+
else
|
|
159
|
+
shows.each do |s|
|
|
160
|
+
window = "#{s[:dusk] ? "dusk" : s[:start].strftime("%H:%M")}-#{s[:finish].strftime("%H:%M")}"
|
|
161
|
+
seqs = s[:sequences].map { |n, secs| "#{n}:#{secs}" }.join(", ")
|
|
162
|
+
puts "#{s[:start].strftime("%a %Y-%m-%d")} #{window.ljust(11)} #{s[:name]} (#{seqs})"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
when "calendar add"
|
|
166
|
+
date, name, *rest = ARGV
|
|
167
|
+
abort "calendar add DATE NAME SEQ[:SECS],..." if rest.empty?
|
|
168
|
+
el.calendar.add(date, name, rest.join(" "), start: opts[:start], finish: opts[:end] || "23:00", through: opts[:until])
|
|
169
|
+
puts "added #{name} on #{date}#{" through #{opts[:until]}" if opts[:until]}"
|
|
170
|
+
when "calendar remove"
|
|
171
|
+
date, *name = ARGV
|
|
172
|
+
abort "calendar remove DATE NAME" if name.empty?
|
|
173
|
+
el.calendar.remove(date, name.join(" "))
|
|
174
|
+
puts "removed #{name.join(" ")} on #{date}"
|
|
175
|
+
|
|
176
|
+
when "sync"
|
|
177
|
+
raise Everlights::Error, "no config at #{config_path}; copy config/everlights.example.yml to start" unless config
|
|
178
|
+
state_path = File.join(File.dirname(config_path), ".sync-state.yml")
|
|
179
|
+
plan = Everlights::Sync.new(el, config, state_path: state_path, adopt: opts[:adopt]).plan
|
|
180
|
+
puts plan
|
|
181
|
+
if plan.empty? then nil
|
|
182
|
+
elsif opts[:dry_run] then puts "\n(dry run: nothing applied)"
|
|
183
|
+
else
|
|
184
|
+
plan.apply!
|
|
185
|
+
puts "\nApplied."
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
else abort parser.help
|
|
189
|
+
end
|
|
190
|
+
rescue Everlights::Error, IndexError, ArgumentError, TypeError, Date::Error, SystemCallError, Net::OpenTimeout => e
|
|
191
|
+
abort "error: #{e.message}"
|
|
192
|
+
end
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Everlights
|
|
4
|
+
# Builds sequences and calendar entries on the bridge from a config (see config/everlights.example.yml).
|
|
5
|
+
#
|
|
6
|
+
# config = Everlights::Sync.load_config("config/everlights.yml")
|
|
7
|
+
# plan = Everlights::Sync.new(el, config, state_path: "config/.sync-state.yml").plan # raises Error listing every config problem
|
|
8
|
+
# puts plan
|
|
9
|
+
# plan.apply!
|
|
10
|
+
#
|
|
11
|
+
# Sync only creates, changes and deletes what it created itself (tracked in the state file).
|
|
12
|
+
# Times are local: set ENV["TZ"] (the CLI uses the config's `timezone:`) before planning.
|
|
13
|
+
class Sync
|
|
14
|
+
MARGIN = 15 * 60 # never add a night that starts sooner than this
|
|
15
|
+
ORDINALS = {"1st" => 1, "first" => 1, "2nd" => 2, "second" => 2, "3rd" => 3, "third" => 3,
|
|
16
|
+
"4th" => 4, "fourth" => 4, "5th" => 5, "fifth" => 5, "last" => -1}.freeze
|
|
17
|
+
PHASE = {sequence: {adopt: 0, create: 0, update: 0, delete: 2, untrack: 2, forget: 2},
|
|
18
|
+
event: Hash.new(1)}.freeze # sequences exist before events use them; deleted after events stop
|
|
19
|
+
|
|
20
|
+
Action = Struct.new(:kind, :op, :name, :detail, :run)
|
|
21
|
+
|
|
22
|
+
Plan = Struct.new(:actions, :skips, :horizon, :sync) do
|
|
23
|
+
def empty? = actions.empty?
|
|
24
|
+
def apply! = sync.apply(self)
|
|
25
|
+
|
|
26
|
+
def to_s
|
|
27
|
+
mark = {create: "+", update: "~", delete: "-", adopt: "@", untrack: "=", forget: "="}
|
|
28
|
+
out = []
|
|
29
|
+
{sequence: "Sequences", event: "Calendar (next #{horizon} days)"}.each do |kind, title|
|
|
30
|
+
rows = actions.select { _1.kind == kind }
|
|
31
|
+
next if rows.empty?
|
|
32
|
+
out << title
|
|
33
|
+
rows.each { |a| out << " #{mark[a.op]} #{a.op.to_s.ljust(7)} #{a.name}#{": #{a.detail}" if a.detail}" }
|
|
34
|
+
end
|
|
35
|
+
lost, other = skips.partition { |_, _, why| why.end_with?(" wins this night") }
|
|
36
|
+
unless other.empty?
|
|
37
|
+
out << "Skipped"
|
|
38
|
+
other.each { |d, rule, why| out << " #{d.strftime("%a %Y-%m-%d")} #{rule}: #{why}" }
|
|
39
|
+
end
|
|
40
|
+
unless lost.empty?
|
|
41
|
+
out << "Nights taken by a higher rule"
|
|
42
|
+
lost.group_by { |_, rule, why| [rule, why.delete_suffix(" wins this night")] }.each do |(rule, winner), rows|
|
|
43
|
+
days = rows.map(&:first)
|
|
44
|
+
span = days.size == 1 ? days[0].strftime("%b %-d") : "#{days[0].strftime("%b %-d")} – #{days[-1].strftime("%b %-d")}"
|
|
45
|
+
out << " #{rule}: #{days.size} night#{"s" unless days.size == 1} to #{winner} (#{span})"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
out << "Nothing to change." if empty?
|
|
49
|
+
out.join("\n")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.load_config(path)
|
|
54
|
+
config = YAML.safe_load_file(path, permitted_classes: [Date]) || {}
|
|
55
|
+
# YAML 1.1 reads the key `on:` as boolean true; put it back
|
|
56
|
+
Array(config["schedule"]).each { |r| r["on"] = r.delete(true) if r.is_a?(Hash) && r.key?(true) }
|
|
57
|
+
config
|
|
58
|
+
rescue Psych::SyntaxError => e
|
|
59
|
+
raise Error, "#{path}: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(client, config, state_path: nil, adopt: false, now: Time.now)
|
|
63
|
+
@c, @cfg, @state_path, @adopt, @now = client, config || {}, state_path, adopt, now
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def plan
|
|
67
|
+
@errors = []
|
|
68
|
+
@state = load_state
|
|
69
|
+
bridge_seqs = @c.sequence_list
|
|
70
|
+
bridge_events = @c.event_list
|
|
71
|
+
actions = @adopt ? adopt(bridge_seqs, bridge_events) : []
|
|
72
|
+
owned_seq_ids = @state["sequences"].values
|
|
73
|
+
owned_event_ids = @state["events"].values
|
|
74
|
+
config_seqs = @cfg["sequences"] || {}
|
|
75
|
+
|
|
76
|
+
# Names a rule can use: built-ins and app-made sequences, plus the config's own.
|
|
77
|
+
seq_ids = {}
|
|
78
|
+
bridge_seqs.each { |s| seq_ids[s["alias"].downcase] ||= s["id"] unless owned_seq_ids.include?(s["id"]) }
|
|
79
|
+
|
|
80
|
+
config_seqs.each do |name, spec|
|
|
81
|
+
spec ||= {}
|
|
82
|
+
pattern = check("sequence #{name.inspect}") { Everlights.colors(spec["colors"]) }
|
|
83
|
+
effects = check("sequence #{name.inspect}") { Everlights.effects(spec["effects"]) }
|
|
84
|
+
@errors << "sequence #{name.inspect}: needs colors" if pattern&.empty?
|
|
85
|
+
clash = bridge_seqs.find { _1["alias"].casecmp?(name) && !owned_seq_ids.include?(_1["id"]) }
|
|
86
|
+
if clash
|
|
87
|
+
@errors << if clash["id"].start_with?(BUILTIN_PREFIX)
|
|
88
|
+
"sequence #{name.inspect} has the same name as a built-in; rename it"
|
|
89
|
+
else
|
|
90
|
+
"sequence #{name.inspect} already exists on the bridge (made in the app?); rename it or run with --adopt"
|
|
91
|
+
end
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
current = bridge_seqs.find { _1["id"] == @state["sequences"][name] }
|
|
95
|
+
id = current ? current["id"] : SecureRandom.uuid
|
|
96
|
+
seq_ids[name.downcase] = id
|
|
97
|
+
next unless pattern && effects
|
|
98
|
+
if current.nil?
|
|
99
|
+
actions << Action.new(:sequence, :create, name, "#{pattern.size} color#{"s" unless pattern.size == 1}",
|
|
100
|
+
-> { @c.create_sequence(name, pattern: pattern, effects: effects, id: id); @state["sequences"][name] = id })
|
|
101
|
+
elsif current["pattern"] != pattern || current["effects"] != effects
|
|
102
|
+
actions << Action.new(:sequence, :update, name, "#{pattern.size} color#{"s" unless pattern.size == 1}",
|
|
103
|
+
-> { @c.update_sequence(current, pattern: pattern, effects: effects) })
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
rules = (@cfg["schedule"] || []).each_with_index.filter_map { |r, i| compile_rule(r || {}, i, seq_ids) }
|
|
108
|
+
dupes = Array(@cfg["schedule"]).filter_map { _1["name"].to_s.strip.downcase if _1.is_a?(Hash) }
|
|
109
|
+
.reject(&:empty?).tally.select { _2 > 1 }.keys
|
|
110
|
+
@errors << "rule names must be unique: #{dupes.join(", ")}" if dupes.any?
|
|
111
|
+
rules.each do |r|
|
|
112
|
+
clash = bridge_events.find { _1["alias"].casecmp?(r[:name]) && !owned_event_ids.include?(_1["id"]) }
|
|
113
|
+
@errors << "rule #{r[:name].inspect} has the same name as calendar entry #{clash["alias"].inspect} made outside sync; rename it or run with --adopt" if clash
|
|
114
|
+
end
|
|
115
|
+
raise Error, "config problems, nothing applied:\n - #{@errors.uniq.join("\n - ")}" if @errors.any?
|
|
116
|
+
|
|
117
|
+
# Which rule gets each night, and when it starts and ends.
|
|
118
|
+
horizon = Integer(@cfg["horizon_days"] || 120)
|
|
119
|
+
today = @now.to_date
|
|
120
|
+
others = bridge_events.reject { owned_event_ids.include?(_1["id"]) }.flat_map do |e|
|
|
121
|
+
e["occurrences"].map { |o| [e["alias"], Time.parse(o["start"]), Time.parse(o["end"])] }
|
|
122
|
+
end
|
|
123
|
+
wanted = Hash.new { |h, k| h[k] = [] }
|
|
124
|
+
skips = []
|
|
125
|
+
(today..today + horizon).each do |d|
|
|
126
|
+
winner, *losers = rules.select { _1[:matches].(d) }
|
|
127
|
+
next unless winner
|
|
128
|
+
losers.each { skips << [d, _1[:name], "#{winner[:name]} wins this night"] }
|
|
129
|
+
s, e = window(winner, d)
|
|
130
|
+
if e.nil?
|
|
131
|
+
skips << [d, winner[:name], "dusk (#{s.strftime("%H:%M")}) is after its end time"]
|
|
132
|
+
elsif s <= @now + MARGIN
|
|
133
|
+
skips << [d, winner[:name], "already started or starts within 15 minutes"]
|
|
134
|
+
elsif (other = others.find { |_, os, oe| os < e && s < oe })
|
|
135
|
+
skips << [d, winner[:name], "#{other[0].inspect} (made outside sync) has this night"]
|
|
136
|
+
else
|
|
137
|
+
wanted[winner[:name]] << [s, e]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# One calendar entry per rule. Nights already under way stay; ended ones are dropped.
|
|
142
|
+
live = ->(event) { event["occurrences"].select { |o| Time.parse(o["start"]) <= @now + MARGIN && Time.parse(o["end"]) > @now } }
|
|
143
|
+
rules.each do |r|
|
|
144
|
+
nights = wanted[r[:name]]
|
|
145
|
+
current = bridge_events.find { _1["id"] == @state["events"][r[:name]] }
|
|
146
|
+
kept = current ? live.(current).map { [Time.parse(_1["start"]), Time.parse(_1["end"])] } : []
|
|
147
|
+
occurrences = kept + nights
|
|
148
|
+
flags = r[:start][:dusk] ? ["StartAtDusk"] : []
|
|
149
|
+
if current.nil?
|
|
150
|
+
next if nights.empty?
|
|
151
|
+
id = SecureRandom.uuid
|
|
152
|
+
actions << Action.new(:event, :create, r[:name], describe(r, nights), lambda {
|
|
153
|
+
@c.create_event(r[:name], sequences: r[:seqs], occurrences: nights, flags: flags, id: id)
|
|
154
|
+
@state["events"][r[:name]] = id
|
|
155
|
+
})
|
|
156
|
+
elsif occurrences.empty?
|
|
157
|
+
actions << Action.new(:event, :delete, r[:name], "no nights left in the window",
|
|
158
|
+
-> { @c.delete_event(current["id"]); @state["events"].delete(r[:name]) })
|
|
159
|
+
elsif changed?(current, r[:seqs], occurrences, flags)
|
|
160
|
+
actions << Action.new(:event, :update, r[:name], describe(r, nights), lambda {
|
|
161
|
+
@c.update_event(current.merge("sequences" => r[:seqs].map { |sid, secs| {"sequenceId" => sid, "duration" => secs} },
|
|
162
|
+
"occurrences" => occurrences.map { |s, e| {"start" => Everlights.stamp(s), "end" => Everlights.stamp(e)} },
|
|
163
|
+
"flags" => flags))
|
|
164
|
+
})
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Rules removed from the config: delete their entries, but never cut off a show that's under way.
|
|
169
|
+
(@state["events"].keys - rules.map { _1[:name] }).each do |name|
|
|
170
|
+
current = bridge_events.find { _1["id"] == @state["events"][name] }
|
|
171
|
+
if current.nil?
|
|
172
|
+
actions << Action.new(:event, :forget, name, "already gone from the bridge", -> { @state["events"].delete(name) })
|
|
173
|
+
elsif (keep = live.(current)).empty?
|
|
174
|
+
actions << Action.new(:event, :delete, name, "removed from config",
|
|
175
|
+
-> { @c.delete_event(current["id"]); @state["events"].delete(name) })
|
|
176
|
+
elsif keep != current["occurrences"]
|
|
177
|
+
actions << Action.new(:event, :update, name, "removed from config; keeping tonight's show, deleted next sync",
|
|
178
|
+
-> { @c.update_event(current.merge("occurrences" => keep)) })
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Sequences removed from the config: delete, unless something made outside sync still plays them.
|
|
183
|
+
(@state["sequences"].keys - config_seqs.keys).each do |name|
|
|
184
|
+
id = @state["sequences"][name]
|
|
185
|
+
if bridge_seqs.none? { _1["id"] == id }
|
|
186
|
+
actions << Action.new(:sequence, :forget, name, "already gone from the bridge", -> { @state["sequences"].delete(name) })
|
|
187
|
+
elsif (user = bridge_events.find { |e| !owned_event_ids.include?(e["id"]) && e["sequences"].any? { _1["sequenceId"] == id } })
|
|
188
|
+
actions << Action.new(:sequence, :untrack, name, "still used by #{user["alias"].inspect}; keeping it, no longer synced",
|
|
189
|
+
-> { @state["sequences"].delete(name) })
|
|
190
|
+
else
|
|
191
|
+
actions << Action.new(:sequence, :delete, name, "removed from config",
|
|
192
|
+
-> { @c.delete_sequence(id); @state["sequences"].delete(name) })
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
Plan.new(actions, skips, horizon, self)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def apply(plan)
|
|
200
|
+
plan.actions.each_with_index.sort_by { |a, i| [PHASE[a.kind][a.op], i] }.each { |a, _| a.run.() }
|
|
201
|
+
ensure
|
|
202
|
+
save_state
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --- dates ---
|
|
206
|
+
|
|
207
|
+
# Anonymous Gregorian algorithm.
|
|
208
|
+
def self.easter(year)
|
|
209
|
+
a, b, c = year % 19, year / 100, year % 100
|
|
210
|
+
d, e = b / 4, b % 4
|
|
211
|
+
g = (b - (b + 8) / 25 + 1) / 3
|
|
212
|
+
h = (19 * a + b - d - g + 15) % 30
|
|
213
|
+
l = (32 + 2 * e + 2 * (c / 4) - h - (c % 4)) % 7
|
|
214
|
+
m = (a + 11 * h + 22 * l) / 451
|
|
215
|
+
Date.new(year, (h + l - 7 * m + 114) / 31, ((h + l - 7 * m + 114) % 31) + 1)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# n = 1..5, or -1 for last; nil when the month has no such day
|
|
219
|
+
def self.nth_weekday(year, month, wday, n)
|
|
220
|
+
if n.negative?
|
|
221
|
+
last = Date.new(year, month, -1)
|
|
222
|
+
return last - (last.wday - wday) % 7
|
|
223
|
+
end
|
|
224
|
+
first = Date.new(year, month, 1)
|
|
225
|
+
d = first + (wday - first.wday) % 7 + 7 * (n - 1)
|
|
226
|
+
d.month == month ? d : nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# NOAA sunrise/sunset algorithm (Almanac for Computers, 1990). Within a minute or two of published times.
|
|
230
|
+
def self.sunset(date, lat, lon)
|
|
231
|
+
rad = Math::PI / 180
|
|
232
|
+
lng_hour = lon / 15.0
|
|
233
|
+
t = date.yday + ((18 - lng_hour) / 24)
|
|
234
|
+
m = 0.9856 * t - 3.289
|
|
235
|
+
l = (m + 1.916 * Math.sin(m * rad) + 0.020 * Math.sin(2 * m * rad) + 282.634) % 360
|
|
236
|
+
ra = (Math.atan(0.91764 * Math.tan(l * rad)) / rad) % 360
|
|
237
|
+
ra = (ra + (l / 90).floor * 90 - (ra / 90).floor * 90) / 15
|
|
238
|
+
sin_dec = 0.39782 * Math.sin(l * rad)
|
|
239
|
+
cos_dec = Math.cos(Math.asin(sin_dec))
|
|
240
|
+
cos_h = (Math.cos(90.833 * rad) - sin_dec * Math.sin(lat * rad)) / (cos_dec * Math.cos(lat * rad))
|
|
241
|
+
raise Error, "no sunset at latitude #{lat} on #{date}" unless cos_h.between?(-1, 1)
|
|
242
|
+
ut = (Math.acos(cos_h) / rad / 15 + ra - 0.06571 * t - 6.622 - lng_hour) % 24
|
|
243
|
+
time = (Time.utc(date.year, date.month, date.day) + ut * 3600).localtime
|
|
244
|
+
time += 86_400 while time.to_date < date # western longitudes: sunset is the next UTC day
|
|
245
|
+
time -= 86_400 while time.to_date > date
|
|
246
|
+
time
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def check(where)
|
|
252
|
+
yield
|
|
253
|
+
rescue Error, ArgumentError, TypeError => e
|
|
254
|
+
@errors << "#{where}: #{e.message}"
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def load_state
|
|
259
|
+
state = @state_path && File.exist?(@state_path) ? YAML.safe_load_file(@state_path) : nil
|
|
260
|
+
{"sequences" => {}, "events" => {}}.merge(state || {})
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def save_state
|
|
264
|
+
return unless @state_path && @state
|
|
265
|
+
File.write(@state_path, "# Written by `everlights sync`: what it created on the bridge. Don't edit.\n#{YAML.dump(@state)}")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def adopt(bridge_seqs, bridge_events)
|
|
269
|
+
actions = []
|
|
270
|
+
(@cfg["sequences"] || {}).each_key do |name|
|
|
271
|
+
next if @state["sequences"].key?(name)
|
|
272
|
+
s = bridge_seqs.find { _1["alias"].casecmp?(name) && !_1["id"].start_with?(BUILTIN_PREFIX) }
|
|
273
|
+
next unless s
|
|
274
|
+
@state["sequences"][name] = s["id"]
|
|
275
|
+
actions << Action.new(:sequence, :adopt, name, "now managed by sync", -> {})
|
|
276
|
+
end
|
|
277
|
+
(@cfg["schedule"] || []).each do |r|
|
|
278
|
+
name = r && r["name"].to_s
|
|
279
|
+
next if name.to_s.empty? || @state["events"].key?(name)
|
|
280
|
+
e = bridge_events.find { _1["alias"].casecmp?(name) }
|
|
281
|
+
next unless e
|
|
282
|
+
@state["events"][name] = e["id"]
|
|
283
|
+
actions << Action.new(:event, :adopt, name, "now managed by sync", -> {})
|
|
284
|
+
end
|
|
285
|
+
actions
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def compile_rule(r, i, seq_ids)
|
|
289
|
+
name = r["name"].to_s.strip
|
|
290
|
+
where = name.empty? ? "rule ##{i + 1}" : "rule #{name.inspect}"
|
|
291
|
+
@errors << "#{where}: needs a name" if name.empty?
|
|
292
|
+
defaults = {"start" => "dusk", "end" => "23:00", "seconds" => DEFAULT_SECONDS}.merge(@cfg["defaults"] || {})
|
|
293
|
+
matches = matcher(r, where)
|
|
294
|
+
start = check(where) { parse_start(r.fetch("start", defaults["start"])) }
|
|
295
|
+
finish = check(where) { parse_hm(r.fetch("end", defaults["end"])) }
|
|
296
|
+
if start&.dig(:dusk) && !(@cfg.dig("location", "lat") && @cfg.dig("location", "lon"))
|
|
297
|
+
@errors << "#{where}: dusk needs `location: {lat:, lon:}` in the config"
|
|
298
|
+
end
|
|
299
|
+
seconds = r.fetch("seconds", defaults["seconds"])
|
|
300
|
+
@errors << "#{where}: needs sequences" if Array(r["sequences"]).empty?
|
|
301
|
+
seqs = Array(r["sequences"]).map do |spec|
|
|
302
|
+
ref, secs = spec.to_s.strip.split(/:(?=\d+\z)/)
|
|
303
|
+
id = seq_ids[ref.to_s.downcase]
|
|
304
|
+
@errors << "#{where}: unknown sequence #{ref.inspect}" unless id
|
|
305
|
+
[id, check(where) { Integer(secs || seconds) }]
|
|
306
|
+
end
|
|
307
|
+
return nil if name.empty? || !matches || !start || !finish || seqs.empty? || seqs.flatten.include?(nil)
|
|
308
|
+
{name: name, matches: matches, start: start, end: finish, seqs: seqs}
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def matcher(r, where)
|
|
312
|
+
on, every, range = r["on"], r["every"], r.key?("from") || r.key?("to")
|
|
313
|
+
if [on, every].compact.size + (range && !every ? 1 : 0) != 1
|
|
314
|
+
@errors << "#{where}: needs exactly one of `on:`, `from:`/`to:`, or `every:`"
|
|
315
|
+
return nil
|
|
316
|
+
end
|
|
317
|
+
return check(where) { on_matcher(on) } if on
|
|
318
|
+
in_range = range ? check(where) { range_matcher(r["from"], r["to"]) } : ->(_) { true }
|
|
319
|
+
return in_range unless every && in_range
|
|
320
|
+
days = check(where) { Array(every).map { weekday(_1) } }
|
|
321
|
+
days && ->(d) { days.include?(d.wday) && in_range.(d) }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def on_matcher(v)
|
|
325
|
+
at = date_expr(v)
|
|
326
|
+
->(d) { (d.year - 1..d.year + 1).any? { at.(_1) == d } } # offsets can cross a year boundary
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def range_matcher(from, to)
|
|
330
|
+
raise Error, "`from:` and `to:` go together" unless from && to
|
|
331
|
+
first_of, last_of = date_expr(from), date_expr(to)
|
|
332
|
+
lambda do |d|
|
|
333
|
+
(d.year - 1..d.year).any? do |y|
|
|
334
|
+
first, last = first_of.(y), last_of.(y)
|
|
335
|
+
last = last_of.(y + 1) if first && last && last < first # e.g. Dec 1 to Jan 6 wraps the new year
|
|
336
|
+
first && last && d.between?(first, last)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# A date expression -> lambda(year) -> Date or nil:
|
|
342
|
+
# "2027-03-14", "Dec 25", "4th Thursday of November", "last Monday of May", "Easter",
|
|
343
|
+
# each optionally shifted by days: "Easter-47", "4th Thursday of November+1"
|
|
344
|
+
def date_expr(v)
|
|
345
|
+
return ->(_) { v } if v.is_a?(Date)
|
|
346
|
+
text = v.to_s.strip
|
|
347
|
+
begin
|
|
348
|
+
base_expr(text)
|
|
349
|
+
rescue Error => e
|
|
350
|
+
m = text.match(/\A(.*\S)\s*([+-])\s*(\d+)\z/) or raise e
|
|
351
|
+
base = base_expr(m[1]) # with an offset, report what's wrong with the date before it
|
|
352
|
+
days = m[3].to_i * (m[2] == "-" ? -1 : 1)
|
|
353
|
+
->(y) { base.(y)&.+(days) }
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def base_expr(text)
|
|
358
|
+
case text
|
|
359
|
+
when /\A\d{4}-\d{1,2}-\d{1,2}\z/
|
|
360
|
+
date = Date.parse(text)
|
|
361
|
+
->(_) { date }
|
|
362
|
+
when /\Aeaster\z/i
|
|
363
|
+
->(y) { Sync.easter(y) }
|
|
364
|
+
when /\A(\S+)\s+(\S+)\s+of\s+(\S+)\z/i
|
|
365
|
+
ordinal, day_name, month_name = $1, $2, $3
|
|
366
|
+
nth = ORDINALS[ordinal.downcase] or raise Error, "can't read #{text.inspect}; try \"4th Thursday of November\" or \"last Monday of May\""
|
|
367
|
+
wday, mon = weekday(day_name), month(month_name)
|
|
368
|
+
->(y) { Sync.nth_weekday(y, mon, wday, nth) }
|
|
369
|
+
else
|
|
370
|
+
m = text.match(/\A([A-Za-z]+\.?)\s+(\d{1,2})\z/) or raise Error, "can't read date #{text.inspect}; try \"Dec 25\" or \"Easter-47\""
|
|
371
|
+
mon, day = month(m[1]), m[2].to_i
|
|
372
|
+
raise Error, "no such date #{text.inspect}" unless Date.valid_date?(2024, mon, day)
|
|
373
|
+
->(y) { Date.valid_date?(y, mon, day) ? Date.new(y, mon, day) : nil } # Feb 29 only in leap years
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Full name or three-letter abbreviation
|
|
378
|
+
def month(s) = name_index(Date::MONTHNAMES, Date::ABBR_MONTHNAMES, s) || raise(Error, "unknown month #{s.inspect}")
|
|
379
|
+
def weekday(s) = name_index(Date::DAYNAMES, Date::ABBR_DAYNAMES, s) || raise(Error, "unknown weekday #{s.inspect}")
|
|
380
|
+
def name_index(full, abbr, s) = [full, abbr].lazy.filter_map { |names| names.index { _1&.casecmp?(s.to_s.delete_suffix(".")) } }.first
|
|
381
|
+
|
|
382
|
+
# "dusk", "dusk+15", "dusk-10" or "HH:MM"
|
|
383
|
+
def parse_start(v)
|
|
384
|
+
return {dusk: true, offset: Integer($1.delete(" "), 10)} if v.to_s.strip =~ /\Adusk\s*([+-]\s*\d+)\z/i
|
|
385
|
+
return {dusk: true, offset: 0} if v.to_s.strip.casecmp?("dusk")
|
|
386
|
+
{dusk: false, hm: parse_hm(v)}
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# YAML reads an unquoted 18:30 as a base-60 number: seconds (66600) in Psych 5+, minutes (1110) before.
|
|
390
|
+
YAML_MINUTE = YAML.safe_load("x: 1:00")["x"] / 60
|
|
391
|
+
|
|
392
|
+
# "18:30", or the number YAML made of an unquoted 18:30
|
|
393
|
+
def parse_hm(v)
|
|
394
|
+
return (v / YAML_MINUTE).divmod(60) if v.is_a?(Integer)
|
|
395
|
+
m = v.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/) or raise Error, "can't read time #{v.inspect}; want HH:MM"
|
|
396
|
+
h, min = m[1].to_i, m[2].to_i
|
|
397
|
+
raise Error, "no such time #{v.inspect}" unless h < 24 && min < 60
|
|
398
|
+
[h, min]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def window(r, d)
|
|
402
|
+
s = if r[:start][:dusk]
|
|
403
|
+
Sync.sunset(d, @cfg.dig("location", "lat").to_f, @cfg.dig("location", "lon").to_f) + r[:start][:offset] * 60
|
|
404
|
+
else
|
|
405
|
+
at(d, r[:start][:hm])
|
|
406
|
+
end
|
|
407
|
+
s = Time.at(s.to_i) # whole seconds, so re-planning compares equal
|
|
408
|
+
e = at(d, r[:end])
|
|
409
|
+
if e <= s
|
|
410
|
+
return [s, nil] if r[:end][0] >= 12 # an evening end already past dusk: no show, not a 24-hour one
|
|
411
|
+
e = at(d + 1, r[:end]) # "01:00" means the next morning
|
|
412
|
+
end
|
|
413
|
+
[s, e]
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def at(d, (h, m)) = Time.new(d.year, d.month, d.day, h, m) # wall clock, so DST doesn't shift shows
|
|
417
|
+
|
|
418
|
+
def changed?(event, seqs, occurrences, flags)
|
|
419
|
+
have = [event["sequences"].map { [_1["sequenceId"], _1["duration"]] },
|
|
420
|
+
event["occurrences"].map { [Time.parse(_1["start"]).to_i, Time.parse(_1["end"]).to_i] },
|
|
421
|
+
event["flags"]]
|
|
422
|
+
have != [seqs, occurrences.map { |s, e| [s.to_i, e.to_i] }, flags]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def describe(r, nights)
|
|
426
|
+
return "nothing new in the window" if nights.empty?
|
|
427
|
+
s, e = nights.first
|
|
428
|
+
start = r[:start][:dusk] ? "dusk (#{s.strftime("%H:%M")})" : s.strftime("%H:%M")
|
|
429
|
+
span = nights.size == 1 ? s.strftime("%b %-d") : "#{s.strftime("%b %-d")} – #{nights.last[0].strftime("%b %-d")}"
|
|
430
|
+
"#{nights.size} night#{"s" unless nights.size == 1}, #{span}, #{start}–#{e.strftime("%H:%M")}"
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
data/lib/everlights.rb
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
# Client for the EverLights Smart Lights Bridge local HTTP API (no auth, port 80).
|
|
7
|
+
# Endpoints from github.com/weswitt/everlights3 OpenAPI spec, verified on firmware B4042.
|
|
8
|
+
#
|
|
9
|
+
# el = Everlights::Client.new("192.168.1.50")
|
|
10
|
+
# el.play "Hallows Eve"
|
|
11
|
+
# el.sequences.add "Candy Cane", "FF0000", "FFFFFF", effects: {chase: 60}
|
|
12
|
+
# el.calendar.add "2026-12-01", "Christmas", "Candy Cane:30,Christmas", through: "2027-01-01"
|
|
13
|
+
# el.calendar.list(limit: 30)
|
|
14
|
+
# el.calendar.remove "2026-12-25", "Christmas"
|
|
15
|
+
module Everlights
|
|
16
|
+
Error = Class.new(StandardError)
|
|
17
|
+
|
|
18
|
+
ALL_ZONES = "000000000000" # what the app writes into zoneSerials for "every zone"
|
|
19
|
+
PERSONAL_GROUP = "Personal/" # where the app files user-made sequences
|
|
20
|
+
BUILTIN_PREFIX = "00000000-0000-0000-0000-" # ids of EverLights' stock sequences
|
|
21
|
+
DEFAULT_SECONDS = 20
|
|
22
|
+
EFFECTS = %w[bars blink chase fade gradient lightning melt pingPong random reverseChase strobe twinkle wipe].freeze
|
|
23
|
+
|
|
24
|
+
class Client
|
|
25
|
+
def initialize(host = nil, timeout: 10)
|
|
26
|
+
host ||= ENV["EVERLIGHTS_HOST"]
|
|
27
|
+
raise Error, "no host: pass one or set EVERLIGHTS_HOST" unless host
|
|
28
|
+
uri = URI("http://#{host}")
|
|
29
|
+
@http = Net::HTTP.new(uri.host, uri.port)
|
|
30
|
+
@http.open_timeout = @http.read_timeout = timeout
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def sequences = Sequences.new(self)
|
|
34
|
+
def calendar = Calendar.new(self)
|
|
35
|
+
|
|
36
|
+
def status = get("/v1")
|
|
37
|
+
def zones = get("/v1/zones")
|
|
38
|
+
def zone_serials = zones.map { _1["serial"] }
|
|
39
|
+
|
|
40
|
+
# Show something right now. During a running show this lasts only until the show's next sequence change.
|
|
41
|
+
def set(*colors, effects: {}, zones: nil)
|
|
42
|
+
(zones || zone_serials).each { set_program(_1, pattern: colors.flatten, effects: effects) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def play(name, zones: nil)
|
|
46
|
+
seq = find_sequence(name)
|
|
47
|
+
(zones || zone_serials).each { set_program(_1, pattern: seq["pattern"], effects: seq["effects"]) }
|
|
48
|
+
seq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Hands control back to the calendar.
|
|
52
|
+
def clear(zones: nil) = (zones || zone_serials).each { clear_program(_1) }
|
|
53
|
+
|
|
54
|
+
# Lights dark: an empty pattern, as theOrakle/everlightsv3 does. A running show resumes at its next change.
|
|
55
|
+
def off(zones: nil) = (zones || zone_serials).each { set_program(_1, pattern: []) }
|
|
56
|
+
|
|
57
|
+
# What each zone shows right now:
|
|
58
|
+
# {serial => {name:, show:, off:, pattern:, effects:}}
|
|
59
|
+
# name: the sequence (the bridge names it during a show; otherwise matched by colors+effects), nil = custom colors
|
|
60
|
+
# show: the running calendar entry it belongs to, if any
|
|
61
|
+
def now_playing(zones: nil)
|
|
62
|
+
seqs = sequence_list
|
|
63
|
+
now = Time.now
|
|
64
|
+
running = calendar.list(limit: 1, now: now).select { _1[:start] <= now && now <= _1[:finish] }
|
|
65
|
+
(zones || zone_serials).to_h do |zone|
|
|
66
|
+
prog = program(zone) || {}
|
|
67
|
+
pattern, effects = prog["pattern"] || [], prog["effects"] || []
|
|
68
|
+
name = prog["alias"] || seqs.find { _1["pattern"] == pattern && _1["effects"] == effects }&.fetch("alias")
|
|
69
|
+
name = nil if pattern.empty?
|
|
70
|
+
show = name && running.find { |s| s[:sequences].any? { |seq_name, _| seq_name == name } }&.fetch(:name)
|
|
71
|
+
[zone, {name: name, show: show, off: pattern.empty?, pattern: pattern, effects: effects}]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# --- raw endpoints ---
|
|
76
|
+
|
|
77
|
+
def program(zone) = get("/v1/zones/#{zone}/sequence")
|
|
78
|
+
|
|
79
|
+
def set_program(zone, pattern:, effects: {})
|
|
80
|
+
request(Net::HTTP::Post, "/v1/zones/#{zone}/sequence",
|
|
81
|
+
colorMode: "rgb", pattern: colors(pattern), effects: effects_body(effects))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def clear_program(zone) = request(Net::HTTP::Delete, "/v1/zones/#{zone}/sequence")
|
|
85
|
+
|
|
86
|
+
def sequence_list = get("/v1/sequences")
|
|
87
|
+
|
|
88
|
+
def find_sequence(name_or_id)
|
|
89
|
+
sequence_list.find { _1["id"] == name_or_id || _1["alias"].casecmp?(name_or_id.to_s.strip) } ||
|
|
90
|
+
raise(Error, "no sequence named #{name_or_id.inspect}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_sequence(name, pattern:, effects: {}, id: SecureRandom.uuid)
|
|
94
|
+
request(Net::HTTP::Post, "/v1/sequences",
|
|
95
|
+
id: id, alias: name, groups: [PERSONAL_GROUP], colorMode: "rgb",
|
|
96
|
+
pattern: colors(pattern), effects: effects_body(effects), lastChanged: stamp(Time.now))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# seq: a hash from #sequence_list; pattern and effects replace its own
|
|
100
|
+
def update_sequence(seq, pattern:, effects: {})
|
|
101
|
+
request(Net::HTTP::Put, "/v1/sequences/#{seq["id"]}",
|
|
102
|
+
seq.merge("pattern" => colors(pattern), "effects" => effects_body(effects), "lastChanged" => stamp(Time.now)))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def delete_sequence(id) = request(Net::HTTP::Delete, "/v1/sequences/#{id}")
|
|
106
|
+
|
|
107
|
+
def event_list = get("/v1/events")
|
|
108
|
+
|
|
109
|
+
# sequences: [[sequence_id, seconds], ...] occurrences: [[start_time, end_time], ...]
|
|
110
|
+
def create_event(name, sequences:, occurrences:, zones: [ALL_ZONES], flags: [], id: SecureRandom.uuid)
|
|
111
|
+
request(Net::HTTP::Post, "/v1/events",
|
|
112
|
+
id: id, alias: name, group: "User Created",
|
|
113
|
+
sequences: sequences.map { |id, secs| {sequenceId: id, duration: secs} },
|
|
114
|
+
zoneSerials: zones,
|
|
115
|
+
occurrences: occurrences.map { |s, e| {start: stamp(s), end: stamp(e)} },
|
|
116
|
+
flags: flags, lastChanged: stamp(Time.now))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# event: a hash from #event_list, edited
|
|
120
|
+
def update_event(event)
|
|
121
|
+
request(Net::HTTP::Put, "/v1/events/#{event["id"]}", event.merge("lastChanged" => stamp(Time.now)))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def delete_event(id) = request(Net::HTTP::Delete, "/v1/events/#{id}")
|
|
125
|
+
|
|
126
|
+
def set_time(time = Time.now) = request(Net::HTTP::Put, "/v1/time", time: stamp(time))
|
|
127
|
+
def reboot = get("/v1/reboot")
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def get(path) = request(Net::HTTP::Get, path)
|
|
132
|
+
|
|
133
|
+
def request(verb, path, body = nil)
|
|
134
|
+
req = verb.new(path, "Content-Type" => "application/json")
|
|
135
|
+
req.body = JSON.generate(body) if body
|
|
136
|
+
res = @http.request(req)
|
|
137
|
+
raise Error, "#{verb::METHOD} #{path} -> #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
138
|
+
res.body.to_s.empty? ? nil : JSON.parse(res.body)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def colors(pattern) = Everlights.colors(pattern)
|
|
142
|
+
def effects_body(effects) = Everlights.effects(effects)
|
|
143
|
+
def stamp(t) = Everlights.stamp(t)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ["#ff0000", ...] -> ["FF0000", ...]
|
|
147
|
+
def self.colors(pattern)
|
|
148
|
+
Array(pattern).map do |c|
|
|
149
|
+
hex = c.to_s.delete_prefix("#").upcase
|
|
150
|
+
raise Error, "bad color #{c.inspect}, want RRGGBB" unless hex.match?(/\A\h{6}\z/)
|
|
151
|
+
hex
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# {chase: 100} or the API's [{"effectType" => "chase", "value" => 100}] -> API form with string keys
|
|
156
|
+
def self.effects(effects)
|
|
157
|
+
(effects || {}).map do |e|
|
|
158
|
+
type, value = e.is_a?(Hash) ? e.values_at("effectType", "value") : e
|
|
159
|
+
raise Error, "unknown effect #{type}, want one of: #{EFFECTS.join(" ")}" unless EFFECTS.include?(type.to_s)
|
|
160
|
+
{"effectType" => type.to_s, "value" => Integer(value || 128)}
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.stamp(t) = (t.is_a?(Time) ? t : Time.parse(t.to_s)).getutc.iso8601(6)
|
|
165
|
+
|
|
166
|
+
class Sequences
|
|
167
|
+
def initialize(client) = @c = client
|
|
168
|
+
|
|
169
|
+
def list(filter = nil) = @c.sequence_list.select { _1["alias"].downcase.include?(filter.to_s.downcase) }
|
|
170
|
+
|
|
171
|
+
def add(name, *colors, effects: {}) = @c.create_sequence(name, pattern: colors.flatten, effects: effects)
|
|
172
|
+
|
|
173
|
+
# Replaces its colors and effects; calendar entries using it keep working.
|
|
174
|
+
def update(name, *colors, effects: {}) = @c.update_sequence(own(name), pattern: colors.flatten, effects: effects)
|
|
175
|
+
|
|
176
|
+
def remove(name)
|
|
177
|
+
seq = own(name)
|
|
178
|
+
users = @c.event_list.select { |e| e["sequences"].any? { _1["sequenceId"] == seq["id"] } }
|
|
179
|
+
raise Error, "#{seq["alias"]} is used by: #{users.map { _1["alias"] }.join(", ")}" if users.any?
|
|
180
|
+
@c.delete_sequence(seq["id"])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def own(name)
|
|
186
|
+
seq = @c.find_sequence(name)
|
|
187
|
+
raise Error, "#{seq["alias"]} is built in, leaving it" if seq["id"].start_with?(BUILTIN_PREFIX)
|
|
188
|
+
seq
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class Calendar
|
|
193
|
+
def initialize(client) = @c = client
|
|
194
|
+
|
|
195
|
+
# Upcoming shows, one hash per night, soonest first.
|
|
196
|
+
def list(limit: 365, now: Time.now)
|
|
197
|
+
names = @c.sequence_list.to_h { [_1["id"], _1["alias"]] }
|
|
198
|
+
horizon = now + limit * 86_400
|
|
199
|
+
@c.event_list.flat_map do |e|
|
|
200
|
+
e["occurrences"].filter_map do |o|
|
|
201
|
+
s, f = Time.parse(o["start"]).localtime, Time.parse(o["end"]).localtime
|
|
202
|
+
next if f < now || s > horizon
|
|
203
|
+
{date: s.to_date, start: s, finish: f, dusk: e["flags"].include?("StartAtDusk"),
|
|
204
|
+
name: e["alias"], event_id: e["id"],
|
|
205
|
+
sequences: e["sequences"].map { [names.fetch(_1["sequenceId"], _1["sequenceId"]), _1["duration"]] }}
|
|
206
|
+
end
|
|
207
|
+
end.sort_by { _1[:start] }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# sequences: "A:20,B:30" or {"A" => 20, "B" => 30} or ["A", "B"]; seconds default to 20.
|
|
211
|
+
# No start: means start at dusk. finish earlier than start means the next morning.
|
|
212
|
+
def add(date, name, sequences, start: nil, finish: "23:00", through: nil)
|
|
213
|
+
first = to_date(date)
|
|
214
|
+
last = through ? to_date(through) : first
|
|
215
|
+
raise Error, "through (#{last}) is before #{first}" if last < first
|
|
216
|
+
seqs = parse_sequences(sequences).map { |ref, secs| [@c.find_sequence(ref)["id"], secs] }
|
|
217
|
+
start_hm = start ? hm(start) : [17, 0] # ponytail: placeholder; the StartAtDusk flag moves it
|
|
218
|
+
end_hm = hm(finish)
|
|
219
|
+
occurrences = (first..last).map do |d|
|
|
220
|
+
s, f = at(d, start_hm), at(d, end_hm)
|
|
221
|
+
[s, f <= s ? at(d + 1, end_hm) : f]
|
|
222
|
+
end
|
|
223
|
+
@c.create_event(name, sequences: seqs, occurrences: occurrences, flags: start ? [] : ["StartAtDusk"])
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Drops that night from every event with this name; deletes events left empty.
|
|
227
|
+
def remove(date, name)
|
|
228
|
+
day = to_date(date)
|
|
229
|
+
removed = 0
|
|
230
|
+
@c.event_list.select { _1["alias"].casecmp?(name) }.each do |e|
|
|
231
|
+
keep, drop = e["occurrences"].partition { Time.parse(_1["start"]).localtime.to_date != day }
|
|
232
|
+
next if drop.empty?
|
|
233
|
+
removed += drop.size
|
|
234
|
+
keep.empty? ? @c.delete_event(e["id"]) : @c.update_event(e.merge("occurrences" => keep))
|
|
235
|
+
end
|
|
236
|
+
raise Error, "nothing named #{name.inspect} on #{day}" if removed.zero?
|
|
237
|
+
removed
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def parse_sequences(seqs)
|
|
243
|
+
pairs = case seqs
|
|
244
|
+
when String then seqs.split(",").map { _1.strip.split(/:(?=\d+\z)/) }
|
|
245
|
+
else seqs.map { Array(_1) } # Hash yields pairs, Array yields names or pairs
|
|
246
|
+
end
|
|
247
|
+
pairs.map { |ref, secs| [ref, Integer(secs || DEFAULT_SECONDS)] }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def to_date(d) = d.respond_to?(:to_date) ? d.to_date : Date.parse(d.to_s)
|
|
251
|
+
def hm(t) = t.to_s.split(":").map { Integer(_1, 10) }
|
|
252
|
+
def at(d, h_m) = Time.new(d.year, d.month, d.day, *h_m) # wall clock, so DST doesn't shift shows
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
require_relative "everlights/sync"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: everlights
|
|
3
|
+
description: Control EverLights permanent outdoor lights through the local bridge using the `everlights` CLI. Use when the user wants to change the house lights now, design a color sequence, or schedule a light show for a date or holiday ("put up Christmas lights for December", "make the roofline orange tonight", "what's scheduled?").
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# EverLights
|
|
7
|
+
|
|
8
|
+
The `everlights` CLI talks to the bridge on the LAN (no auth). It needs `EVERLIGHTS_HOST` (bridge IP) or `--host`. Lists print text (add `--json` for JSON); `status`, `zones` and `show` print JSON.
|
|
9
|
+
|
|
10
|
+
## Before you write anything
|
|
11
|
+
|
|
12
|
+
1. Run `everlights calendar list` and `everlights show` (names what's playing and which show it's from) first (`everlights bridge` if something seems broken). Report what's already scheduled or showing.
|
|
13
|
+
2. Confirm before every write: `set`, `play`, `off`, `clear`, `sequence add/update/remove`, `calendar add/remove` and `bridge time`. These change real lights on a real house, so state the exact command you'll run.
|
|
14
|
+
3. Only run `bridge reboot --yes` when the user explicitly asks; it interrupts the lights. The CLI refuses to remove built-in sequences, or sequences an event still uses.
|
|
15
|
+
|
|
16
|
+
## Concepts
|
|
17
|
+
|
|
18
|
+
- **Zone**: one run of pixels, identified by serial. `everlights zones` lists them. Commands default to all zones; `--zone SERIAL` narrows one.
|
|
19
|
+
- **Sequence**: a saved pattern of hex colors (`RRGGBB`, repeated along the run) plus up to a few effects. The bridge has about 96 built-ins (`everlights sequence list xmas` searches by name; `*` marks user-made ones). Sequences added here show up in the EverLights app under Personal. Built-in names include "Birthday", so give personal sequences distinct names (e.g. "My Birthday").
|
|
20
|
+
- **Effect**: `--effect TYPE=N`, N 0-255 (default 128; higher seems faster). Types: bars blink chase fade gradient lightning melt pingPong random reverseChase strobe twinkle wipe. Built-ins often stack two, e.g. `fade` + `reverseChase`.
|
|
21
|
+
- **Manual program** (`set`/`play`): shows immediately. During a scheduled show, it only lasts until the show's next sequence change (about 20 seconds). Outside a show it stays. `clear` hands control back to the calendar.
|
|
22
|
+
- **Calendar entry**: a scheduled show. It cycles its sequences (`NAME:SECONDS`, default 20) during each night's window. The bridge stores it, so it runs with no computer on. Default window: dusk to 23:00.
|
|
23
|
+
|
|
24
|
+
## Recipes
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
everlights play "Hallows Eve" # built-in sequence now
|
|
28
|
+
everlights set FF0000 00FF00 --effect twinkle=100 # ad-hoc colors now
|
|
29
|
+
everlights off # dark
|
|
30
|
+
everlights clear # back to the calendar
|
|
31
|
+
|
|
32
|
+
everlights sequence add "Candy Cane" FF0000 FF0000 FFFFFF FFFFFF --effect chase=60
|
|
33
|
+
everlights sequence update "Candy Cane" FF0000 00FF00 --effect chase=60
|
|
34
|
+
everlights sequence remove "Candy Cane"
|
|
35
|
+
|
|
36
|
+
everlights calendar list --limit 30 # next 30 days (default 365)
|
|
37
|
+
everlights calendar add 2026-12-01 Christmas "Candy Cane:30,Christmas" --until 2027-01-01
|
|
38
|
+
everlights calendar add 2026-12-31 "New Year" "New Year's" --start 20:00 --end 01:00
|
|
39
|
+
everlights calendar remove 2026-12-24 Christmas # one night; removes the entry if it was the last
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Dates and times use the machine's local time zone. An `--end` earlier than the start means the next morning. `--until` repeats nightly and stays correct across DST changes. Add `--json` to `list` commands for machine-readable output.
|
|
43
|
+
|
|
44
|
+
### Ruby (same operations)
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
require "everlights"
|
|
48
|
+
el = Everlights::Client.new("192.168.1.50") # or ENV["EVERLIGHTS_HOST"]
|
|
49
|
+
el.play "Hallows Eve"; el.set "FF0000", "00FF00", effects: {twinkle: 100}; el.clear
|
|
50
|
+
el.sequences.list("xmas"); el.sequences.add("Candy Cane", "FF0000", "FFFFFF", effects: {chase: 60}); el.sequences.remove("Candy Cane")
|
|
51
|
+
el.calendar.list(limit: 30) # [{date:, start:, finish:, dusk:, name:, event_id:, sequences: [[name, secs]]}]
|
|
52
|
+
el.calendar.add "2026-12-01", "Christmas", "Candy Cane:30,Christmas", through: "2027-01-01" # or {"Candy Cane" => 30}
|
|
53
|
+
el.calendar.add "2026-12-31", "New Year", ["New Year's"], start: "20:00", finish: "01:00"
|
|
54
|
+
el.calendar.remove "2026-12-24", "Christmas"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Sync (planning shows months ahead)
|
|
58
|
+
|
|
59
|
+
When the user wants a recurring or seasonal plan ("Christmas every December", "Fridays in football season"), edit `config/everlights.yml` rather than adding calendar entries one by one. The format reference is `config/everlights.example.yml`.
|
|
60
|
+
|
|
61
|
+
1. Edit `sequences:` or `schedule:`. Rules higher in the list win nights they share.
|
|
62
|
+
2. Run `everlights sync --dry-run` and show the user the plan, including the Skipped section.
|
|
63
|
+
3. Run `everlights sync` only after they confirm.
|
|
64
|
+
|
|
65
|
+
Sync only changes what it created itself. Config problems abort before anything is written; fix every one it lists. Use `--adopt` only when the user says the state file was lost.
|
|
66
|
+
|
|
67
|
+
## Unverified
|
|
68
|
+
(tell the user if it matters)
|
|
69
|
+
|
|
70
|
+
- Whether "dusk" shows really start at dusk. The bridge stores a 17:00 placeholder, so they may start at 17:00. Use `--start` when the exact time matters.
|
|
71
|
+
- What N means for each effect.
|
|
72
|
+
- Whether a calendar entry overlapping another replaces it or conflicts. Check `calendar list` afterwards.
|
|
73
|
+
- `calendar remove` on a night that isn't an entry's last edits the event with a PUT. The API spec has it; not yet tried on the bridge.
|
metadata
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: everlights
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ricky Chilcott
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Control EverLights permanent lights over the LAN: show colors, manage
|
|
13
|
+
sequences, and schedule shows ahead on the bridge''s calendar. Unofficial; stdlib
|
|
14
|
+
only.'
|
|
15
|
+
email: ricky@rickychilcott.com
|
|
16
|
+
executables:
|
|
17
|
+
- everlights
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- exe/everlights
|
|
24
|
+
- lib/everlights.rb
|
|
25
|
+
- lib/everlights/sync.rb
|
|
26
|
+
- skills/everlights/SKILL.md
|
|
27
|
+
homepage: https://github.com/rickychilcott/everlights
|
|
28
|
+
licenses:
|
|
29
|
+
- MIT
|
|
30
|
+
metadata:
|
|
31
|
+
source_code_uri: https://github.com/rickychilcott/everlights
|
|
32
|
+
bug_tracker_uri: https://github.com/rickychilcott/everlights/issues
|
|
33
|
+
rubygems_mfa_required: 'true'
|
|
34
|
+
rdoc_options: []
|
|
35
|
+
require_paths:
|
|
36
|
+
- lib
|
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3.1'
|
|
42
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
requirements: []
|
|
48
|
+
rubygems_version: 3.7.1
|
|
49
|
+
specification_version: 4
|
|
50
|
+
summary: Client and CLI for the EverLights Smart Lights Bridge local API
|
|
51
|
+
test_files: []
|