everlights 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5470906a45eb2946bbf14cdba11dc89917ad92244e2b224f655513ff944ee66a
4
- data.tar.gz: 1c6f03b3cfa021d8c16692fb555d39a15416c54a82cda3058fe15faae585049b
3
+ metadata.gz: ee4915d68adebdaaaa5d1ea02be82d7530ebbdee06fbfd2905b73aa49fa9cf6e
4
+ data.tar.gz: f6ed7abeccd0e26b23795679e3cf8dcf876b73163cd684d9ffe109ceadc8a085
5
5
  SHA512:
6
- metadata.gz: ef7f03a9d96aa9fbf041cc566bc4e14c7960a9c070630a5633353f55a4acb111aad1092e17abe928673f4225a21c50b75eb0a5195113f30f8d39291b1cbc58c4
7
- data.tar.gz: 815c442cc1d22a33676bec89a25578b70463c10be529ce77e3699c1c355db5b11a567f2ebca9b76a092bae68b65fbd5b23deee80bfa25b087cdb7f79706209ee
6
+ metadata.gz: 011d597b5b53676084adad56466ddb9dde0fa77f8e6c6a37264078495a10959e3350a90e0d365afbcf24c4c0a3a0188b0dd3476808a1fc10bf9f63761de0daee
7
+ data.tar.gz: d99fd9e1c63b2d3e70edd0e0a0ce3cc072a677f4a3c4734b65d90bcdeb45662f2f5e1f42f49eea37a9b5fbdbd6b4d59060492a1478aca51dedb7a165cdd6050a
data/README.md CHANGED
@@ -26,6 +26,7 @@ Requires Ruby 3.1+.
26
26
 
27
27
  ```sh
28
28
  everlights bridge # firmware, network, cloud, zone health
29
+ everlights bridge health # ok/WARN checks; exits 2 on any warning
29
30
  everlights show # what's playing, e.g. "Halloween Fade (Halloween show)"
30
31
 
31
32
  everlights play "Hallows Eve" # a saved sequence, now
@@ -89,6 +90,8 @@ schedule: # higher in the list wins a shared night
89
90
  To keep the calendar topped up, run it on a schedule, for example monthly from cron:
90
91
  `0 9 1 * * cd ~/everlights && everlights sync >> config/sync.log 2>&1`
91
92
 
93
+ To only be told when it needs a sync, use `everlights sync --check`. It prints the plan and changes nothing. It exits 0 when the bridge matches the config, 2 when it needs a sync, and 1 on an error.
94
+
92
95
  ## Ruby
93
96
 
94
97
  ```ruby
@@ -161,7 +164,9 @@ Observed on the bridge (`ruby -Ilib test/live_check.rb` repeats the round trip):
161
164
  ruby -Ilib -e 'Dir["test/*_test.rb"].each { require "./#{_1}" }'
162
165
  ```
163
166
 
164
- The tests run against a fake bridge, so no hardware is needed.
167
+ The tests run against a fake bridge, so no hardware is needed. CI runs them on every push and pull request.
168
+
169
+ To release, bump the version in `everlights.gemspec`, commit, then tag and push (`git tag v0.2.0 && git push origin v0.2.0`). The release workflow tests, then publishes to RubyGems and GitHub Packages and creates a GitHub release with the `.gem` attached.
165
170
 
166
171
  ## Credits
167
172
 
data/exe/everlights CHANGED
@@ -7,6 +7,7 @@ USAGE = <<~TXT
7
7
 
8
8
  Bridge:
9
9
  bridge firmware, network, cloud, zone health
10
+ bridge health ok/WARN per check; exit 2 on any warning (for cron)
10
11
  bridge time [TIME] set the bridge clock (default now)
11
12
  bridge reboot --yes restart the bridge
12
13
  zones raw zone data (JSON)
@@ -32,6 +33,7 @@ USAGE = <<~TXT
32
33
 
33
34
  Sync (config/everlights.yml: sequences + schedule, see config/everlights.example.yml):
34
35
  sync [--dry-run] [--adopt] build sequences and the calendar ahead; prints the plan
36
+ sync --check plan only; exit 2 if the bridge needs a sync (for cron)
35
37
 
36
38
  Options:
37
39
  TXT
@@ -53,6 +55,7 @@ parser = OptionParser.new do |o|
53
55
  o.on("--json", "JSON output for lists, show and bridge") { opts[:json] = true }
54
56
  o.on("--yes", "confirm bridge reboot") { opts[:yes] = true }
55
57
  o.on("--dry-run", "sync: print the plan only") { opts[:dry_run] = true }
58
+ o.on("--check", "sync: plan only, exit 2 if anything would change") { opts[:check] = true }
56
59
  o.on("--adopt", "sync: take over bridge items whose names match the config") { opts[:adopt] = true }
57
60
  end
58
61
  parser.parse!
@@ -70,7 +73,7 @@ begin
70
73
  zones = opts[:zones]
71
74
  cmd = ARGV.shift
72
75
  cmd = "#{cmd} #{ARGV.shift}" if %w[sequence calendar].include?(cmd) ||
73
- (cmd == "bridge" && %w[time reboot].include?(ARGV.first))
76
+ (cmd == "bridge" && %w[health time reboot].include?(ARGV.first))
74
77
  arg = ARGV.join(" ")
75
78
 
76
79
  case cmd
@@ -91,6 +94,25 @@ begin
91
94
  puts " last request #{at.(z["lastRequestDate"])}, response #{at.(z["lastResponseDate"])}, reboots #{z["rebootCount"]}"
92
95
  end
93
96
  end
97
+ when "bridge health"
98
+ # ponytail: thresholds are guesses from one bridge; tune when real failures show what matters
99
+ b = el.status
100
+ ago = ->(v) { v ? ((Time.now - Time.parse(v)) / 60).round : Float::INFINITY }
101
+ mins = ->(m) { m.infinite? ? "never" : "#{m} min ago" }
102
+ cloud, radio = ago.(b["lastServerCheckin"]), ago.(b["lastRadioResponse"])
103
+ checks = [
104
+ [!!b["serverReachable"] && cloud <= 15, "cloud", "#{b["serverReachable"] ? "reachable" : "unreachable"}, checked in #{mins.(cloud)}"],
105
+ [!!b.dig("network", "ntp"), "clock", "ntp #{b.dig("network", "ntp") ? "on" : "off"}"],
106
+ [b["minFreeHeap"].to_i >= 10_000, "memory", "lowest free since boot #{b["minFreeHeap"]} bytes (warn under 10000)"],
107
+ [radio <= 10, "radio", "answered #{mins.(radio)}, #{b["radioRebootCount"]} radio reboots"],
108
+ *b["zones"].map do |z|
109
+ last = ago.(z["lastResponseDate"])
110
+ [!!z["active"] && last <= 10, "zone #{z["serial"]}", "#{z["active"] ? "active" : "inactive"}, answered #{mins.(last)}, #{z["rebootCount"]} reboots"]
111
+ end,
112
+ [:info, "reboots", "#{b["rebootCount"]} total, last reset reason #{b["resetReason"]}"]
113
+ ]
114
+ checks.each { |ok, name, text| puts "#{{info: "info", true => "ok ", false => "WARN"}[ok]} #{name.ljust(19)} #{text}" }
115
+ exit 2 unless checks.all? { _1[0] }
94
116
  when "bridge time"
95
117
  time = ARGV.empty? ? Time.now : Time.parse(arg)
96
118
  el.set_time(time)
@@ -179,6 +201,7 @@ begin
179
201
  plan = Everlights::Sync.new(el, config, state_path: state_path, adopt: opts[:adopt]).plan
180
202
  puts plan
181
203
  if plan.empty? then nil
204
+ elsif opts[:check] then exit 2
182
205
  elsif opts[:dry_run] then puts "\n(dry run: nothing applied)"
183
206
  else
184
207
  plan.apply!
@@ -33,21 +33,31 @@ module Everlights
33
33
  rows.each { |a| out << " #{mark[a.op]} #{a.op.to_s.ljust(7)} #{a.name}#{": #{a.detail}" if a.detail}" }
34
34
  end
35
35
  lost, other = skips.partition { |_, _, why| why.end_with?(" wins this night") }
36
+ dark, other = other.partition { |_, _, why| why.start_with?("dusk (") }
36
37
  unless other.empty?
37
38
  out << "Skipped"
38
39
  other.each { |d, rule, why| out << " #{d.strftime("%a %Y-%m-%d")} #{rule}: #{why}" }
39
40
  end
41
+ unless dark.empty?
42
+ out << "No show: dusk is after the end time"
43
+ dark.group_by { _2 }.each do |rule, rows|
44
+ out << " #{rule}: #{nights(rows.map(&:first))} (dusk up to #{rows.map { _3[/\d\d:\d\d/] }.max}, ends #{rows[0][2][/(\d\d:\d\d) end/, 1]})"
45
+ end
46
+ end
40
47
  unless lost.empty?
41
48
  out << "Nights taken by a higher rule"
42
49
  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})"
50
+ out << " #{rule}: #{nights(rows.map(&:first)).sub(", ", " to #{winner} (")})"
46
51
  end
47
52
  end
48
53
  out << "Nothing to change." if empty?
49
54
  out.join("\n")
50
55
  end
56
+
57
+ def nights(days) # "3 nights, Jun 26 – Jun 28"
58
+ span = days.size == 1 ? days[0].strftime("%b %-d") : "#{days[0].strftime("%b %-d")} – #{days[-1].strftime("%b %-d")}"
59
+ "#{days.size} night#{"s" unless days.size == 1}, #{span}"
60
+ end
51
61
  end
52
62
 
53
63
  def self.load_config(path)
@@ -128,7 +138,7 @@ module Everlights
128
138
  losers.each { skips << [d, _1[:name], "#{winner[:name]} wins this night"] }
129
139
  s, e = window(winner, d)
130
140
  if e.nil?
131
- skips << [d, winner[:name], "dusk (#{s.strftime("%H:%M")}) is after its end time"]
141
+ skips << [d, winner[:name], "dusk (#{s.strftime("%H:%M")}) is after its #{format("%02d:%02d", *winner[:end])} end"]
132
142
  elsif s <= @now + MARGIN
133
143
  skips << [d, winner[:name], "already started or starts within 15 minutes"]
134
144
  elsif (other = others.find { |_, os, oe| os < e && s < oe })
@@ -9,7 +9,7 @@ The `everlights` CLI talks to the bridge on the LAN (no auth). It needs `EVERLIG
9
9
 
10
10
  ## Before you write anything
11
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.
12
+ 1. Run `everlights calendar list` and `everlights show` (names what's playing and which show it's from) first (`everlights bridge health` if something seems broken; it marks problems WARN). Report what's already scheduled or showing.
13
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
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
15
 
@@ -59,7 +59,7 @@ el.calendar.remove "2026-12-24", "Christmas"
59
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
60
 
61
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.
62
+ 2. Run `everlights sync --dry-run` and show the user the plan, including the Skipped and No show sections. `everlights sync --check` is the same plan, but exits 2 when anything would change.
63
63
  3. Run `everlights sync` only after they confirm.
64
64
 
65
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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: everlights
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ricky Chilcott
@@ -31,6 +31,7 @@ metadata:
31
31
  source_code_uri: https://github.com/rickychilcott/everlights
32
32
  bug_tracker_uri: https://github.com/rickychilcott/everlights/issues
33
33
  rubygems_mfa_required: 'true'
34
+ github_repo: ssh://github.com/rickychilcott/everlights
34
35
  rdoc_options: []
35
36
  require_paths:
36
37
  - lib
@@ -45,7 +46,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
45
46
  - !ruby/object:Gem::Version
46
47
  version: '0'
47
48
  requirements: []
48
- rubygems_version: 3.7.1
49
+ rubygems_version: 4.0.20
49
50
  specification_version: 4
50
51
  summary: Client and CLI for the EverLights Smart Lights Bridge local API
51
52
  test_files: []