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 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: []