Skip to content

Instantly share code, notes, and snippets.

@skull-squadron
Last active December 2, 2025 08:51
Show Gist options
  • Select an option

  • Save skull-squadron/b7035b9c6ed5962374774df08851bda2 to your computer and use it in GitHub Desktop.

Select an option

Save skull-squadron/b7035b9c6ed5962374774df08851bda2 to your computer and use it in GitHub Desktop.
Nest Thermostat CLI - in Ruby, primitive but functional
#!/usr/bin/env ruby
# frozen_string_literal: true
# ==== MIT License ====
# Copyright © 2025 <copyright holders>
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
# documentation files (the “Software”), to deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
# persons to whom the Software is furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all copies or
# substantial portions of the Software.
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# Google, Google Nest, Nest are trademarks and/or service marks of Nest Labs, Inc., Alphabet Inc., and/or Google LLC.
# 0. Requires paying the $5 Goog/Nest dev API "tax"
# https://console.nest.google.com/device-access/
#
# 1. Requires creating a Google developer account (free), project, and a new OAuth 2.0 Client "OAuth 2.0 Client IDs"
# https://console.cloud.google.com/apis/credentials
#
# 2. Requires creating and configuring a Goog/Nest project
# https://console.nest.google.com/device-access/project-list
#
# 3. Must first configure ~/.config/nest/config.json like so:
#
# {
# "client_id": "{{your_goog_oauth_client_id}}",
# "client_secret": "{{your_goog_oauth_client_secret}}",
# "project_id": "{{your_goog_nest_project_id}}",
# "units": "f", ### optional: force f or c, defaults to wahtever the thermostat is set to
# "city": "{{your_city_name_only_or_zipcode_for_weather_lookup}}"
# }
#
# More information:
# - https://developers.google.com/nest/device-access/api/thermostat
# - https://developers.google.com/nest/device-access/registration
#
# rubocop:disable Lint/MissingCopEnableDirective
# rubocop:disable Layout/HashAlignment
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Naming/AccessorMethodName:
# rubocop:disable Style/CommentedKeyword
# rubocop:disable Style/Documentation
# rubocop:disable Style/TrailingCommaInArguments
# rubocop:disable Style/TrailingCommaInArrayLiteral
# rubocop:disable Style/TrailingCommaInHashLiteral
raise 'This script requires Ruby 3+' unless Integer(RbConfig::CONFIG['MAJOR']) >= 3
require 'json'
require 'net/http'
require 'pp'
require 'uri'
module Lazy
refine ::Kernel do
def lazy(method_name, &block)
raise "'#{method_name}' block required" unless block
raise "'#{method_name}' must be a valid identifier" unless method_name.to_s =~ /\A[a-z_][a-z0-9_]*[!?]?\z/i
ivar = "@#{method_name}"
ivar = ivar[..-2] if '!?'.include?(ivar[-1])
define_method(method_name) do
return instance_variable_get(ivar) if instance_variable_defined?(ivar)
instance_variable_set(ivar, block.call)
end
end
end
end
using Lazy
if ENV['DEBUG']
def debug(*args) = warn(*args)
else
def debug(*_args) = nil
end
lazy(:color_term?) { $stdout.tty? && ENV['TERM'] =~ /color/i }
lazy(:unicode_term?) { `locale` =~ /UTF-?8/i }
module Blank
unless method_defined?(:blank?)
refine ::Kernel do
def blank? = !self
def present? = !blank?
end
refine ::NilClass do
def blank? = true
end
refine ::String do
def blank? = strip.empty?
end
end
end # module Blank
using Blank
module NonBlankString
refine ::Kernel do
def non_blank_string?(_arg = nil) = false
end
refine ::String do
def non_blank_string?(arg = nil)
case arg
when nil
!strip.empty?
when Regexp
self =~ arg
else
self == arg.to_s
end
end
end
end # module NonBlankString
using NonBlankString
module Colors
ALL = {
red: [1, 91],
blue: [1, 94],
orange: [1, 38, 5, 202],
purple: [1, 95],
}.freeze
refine ::String do
if color_term?
::Colors::ALL.each_pair { |m, c| define_method(m) { "#{_code(c)}#{self}#{_reset}" } }
def reset = "#{self}#{_reset}"
else
::Colors::ALL.each_pair { |m, _| define_method(m) { self } }
def reset = self
end
private
def _reset = _code(0)
def _code(*args) = "\e[#{args.join(';')}m"
end
end # module Colors
using Colors
module Http
module_function
def process_response(res)
case res
when Net::HTTPSuccess
JSON.parse(res.body)
when String
JSON.parse(res)
else
debug '--------- class --------'
debug res.class
debug '--------- inspect --------'
debug res.inspect
JSON.parse('')
end
end
def get(uri, query_options = {}, headers = {})
uri = URI(uri.to_s) unless uri.is_a?(URI)
uri.query = URI.encode_www_form(query_options) if query_options.is_a?(Hash) && query_options.any?
debug '------------------------------'
debug "GET #{uri}"
debug '------------------------------'
headers = { 'Content-Type' => 'application/json' }.merge(headers)
debug '------------------------------'
debug "request headers #{headers.inspect}"
debug '------------------------------'
res = Net::HTTP.get(uri, headers)
process_response(res)
end
def generic_post(uri, headers = {}, &)
uri = URI(uri.to_s) unless uri.is_a?(URI)
req = Net::HTTP::Post.new(uri)
req['Content-Type'] = 'application/json'
headers.each { |k, v| req[k] = v }
yield(req) if block_given?
debug '====================='
debug "POST #{uri.to_s}"
debug '====================='
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(req)
end
process_response(res)
end
def post(uri, body = nil, headers = {})
generic_post(uri, headers) do |req|
if body
debug '------ body ------'
debug body.to_s
debug '------------------'
req.body = body.to_s
else
debug '------ (no body) ------'
end
end
end
def post_form(uri, form_data = {}, headers = {})
generic_post(uri, headers) do |req|
debug '------ form data ------'
debug form_data.to_s
debug '-----------------------'
req.form_data = form_data
end
end
end # module Http
class Weather
ENDPOINT = 'https://api.open-meteo.com/v1'
def initialize(city_only_or_zipcode = nil)
x = city_only_or_zipcode || Config.city
raise 'Weather.new() or Config.city parameter must be a String' unless x.non_blank_string?
@city_only_or_zipcode = x
end
def city = geocoder.name
def current_temp = current_readings&.[]('temperature_2m')
def current_humid = current_readings&.[]('relative_humidity_2m')
def current_conds = current_readings&.[]('weather_code')
private
def current_readings = @current_readings ||= _current_readings
def _current_readings
lat = geocoder.lat
long = geocoder.long
return unless lat && long
json = Http.get(
"#{ENDPOINT}/forecast",
'latitude' => lat,
'longitude' => long,
'current' => %w[temperature_2m relative_humidity_2m weather_code].join(',')
)
debug json.inspect
json&.[]('current')
end
def geocoder = @geocoder ||= Geocoding.new(@city_only_or_zipcode)
class Geocoding
ENDPOINT = 'https://geocoding-api.open-meteo.com/v1'
def initialize(city_only_or_zipcode)
x = city_only_or_zipcode
raise 'Geocoding.new() must be a String' unless x.non_blank_string?
@name = x
end
def lat = record&.[]('latitude')
def long = record&.[]('longitude')
def name = record&.[]('name')
private
def _record
json = Http.get(
"#{ENDPOINT}/search",
'name' => @name.to_s,
'count' => 1,
'format' => 'json',
)
json['results'][0]
end
def record = @record ||= _record
end # class Geocoding
end # class Weather
module API
module_function
OAUTH_ENDPOINT = 'https://www.googleapis.com/oauth2/v4/token'
ENDPOINT = 'https://smartdevicemanagement.googleapis.com/v1'
DEFAULT_SCOPE = 'https://www.googleapis.com/auth/sdm.service'
DEFAULT_REDIRECT_URI = 'https://www.google.com'
def grant_uri(redirect_uri = nil, scope = nil)
redirect_uri ||= DEFAULT_REDIRECT_URI
scope ||= DEFAULT_SCOPE
raise ArgumentError, 'redirect_uri is required' unless redirect_uri.non_blank_string?(%r{\Ahttps://.+\z})
raise ArgumentError, 'scope is required' unless scope.non_blank_string?
'https://nestservices.google.com' \
"/partnerconnections/#{Config.project_id}/auth" \
'?' \
"redirect_uri=#{redirect_uri}" \
'&access_type=offline' \
'&prompt=consent' \
"&client_id=#{Config.client_id}" \
'&response_type=code' \
"&scope=#{scope}"
end
def grant_usage
warn '=== Visit this url in a web browser ==='
warn grant_uri
warn
warn '=== Then rerun with CODE env var ==='
warn 'Rerun this with CODE=\'{{full url from final redirect to google.com}}\''
warn
exit 1
end
def get_access_and_refresh_tokens(code, redirect_uri = nil)
debug 'Getting access and refresh tokens'
redirect_uri ||= DEFAULT_REDIRECT_URI
code = code&.sub(/&.*/, '')&.sub(/.*=/, '')
raise ArgumentError, 'code is required' unless code.is_a?(String) && !code.empty?
raise ArgumentError, 'redirect_uri is required' unless redirect_uri.is_a?(String) && !redirect_uri.empty?
uri = URI(OAUTH_ENDPOINT)
form_data = {
'client_id' => Config.client_id.to_s,
'client_secret' => Config.client_secret.to_s,
'code' => code.to_s,
'grant_type' => 'authorization_code',
'redirect_uri' => redirect_uri.to_s,
}
json = Http.post_form(uri, form_data)
debug "json: #{json}"
[json['access_token'], json['refresh_token']]
rescue JSON::ParserError
grant_usage
end
def get_access_token_from_refresh_token(refresh_token)
raise ArgumentError, 'refresh_token is required' unless refresh_token.is_a?(String) && !refresh_token.empty?
form_data = {
'client_id' => Config.client_id.to_s,
'client_secret' => Config.client_secret.to_s,
'refresh_token' => refresh_token.to_s,
'grant_type' => 'refresh_token',
}
debug "Options #{form_data.inspect}"
json = Http.post_form(OAUTH_ENDPOINT, form_data)
debug "json: #{json}"
json['access_token']
rescue JSON::ParserError
grant_usage
end
def get(path, project_id, access_token)
raise ArgumentError, 'path is required' unless path.non_blank_string?
raise ArgumentError, 'project_id is required' unless project_id.non_blank_string?
raise ArgumentError, 'access_token is required' unless access_token.non_blank_string?
uri = "#{ENDPOINT}/enterprises/#{project_id}/#{path}"
Http.get(uri, {}, 'Authorization' => "Bearer #{access_token}")
end
def post(path, project_id, access_token, data)
raise ArgumentError, 'path is required' unless path.non_blank_string?
raise ArgumentError, 'project_id is required' unless project_id.non_blank_string?
raise ArgumentError, 'access_token is required' unless access_token.non_blank_string?
raise ArgumentError, 'data is required to be a Hash' unless data.is_a?(Hash)
uri = URI("#{ENDPOINT}/enterprises/#{project_id}/#{path}")
Http.post(uri, data.to_json, 'Authorization' => "Bearer #{access_token}")
end
end # module API
module Config
module_function
def xdg_config_home = ENV.fetch('XDG_CONFIG_HOME', "#{ENV.fetch('HOME')}/.config")
def default_filename = "#{xdg_config_home}/nest/config.json"
def client_id = read_required_key 'client_id'
def client_secret = read_required_key 'client_secret'
def project_id = read_required_key 'project_id'
def refresh_token = read['refresh_token']
def access_token = read['access_token']
def units = read['units'].to_s.downcase[0]&.to_sym
def city = read['city']
C_SYMBOL = unicode_term? ? '°C' : 'C'
F_SYMBOL = unicode_term? ? '°F' : 'F'
def c_to_f(temp_c) = (temp_c * 9.0) / 5 + 32
def format_temp(temp_c, units_default = nil)
case units || units_default
when :c
format("%.01f #{C_SYMBOL}", temp_c)
else # nil, :f
format("%.01f #{F_SYMBOL}", c_to_f(temp_c))
end
end
def filename = @filename ||= ENV.fetch('NEST_CONFIG', default_filename)
def read
raw = IO.read(filename)
JSON.parse(raw)
rescue Errno::ENOENT
{}
end
def read_required_key(key)
x = read[key]
raise ArgumentError, "#{key} is required. It's configured in #{filename}" unless x.non_blank_string?
x
end
def write(options)
config = read
config.merge!(options)
dir = File.dirname(filename)
FileUtils.mkdir_p(dir) unless File.directory?(dir)
IO.write(filename, JSON.pretty_generate(config))
File.chmod(0o600, filename)
end
def fix_access_token
debug 'Fixing access token'
if (code = ENV['CODE']).non_blank_string?
debug '>>>>>>>>>>> Getting new access token, which requires CODE environment variable'
access_token, refresh_token = API.get_access_and_refresh_tokens(code)
write(
'access_token' => access_token,
'refresh_token' => refresh_token
)
elsif !(token = Config.refresh_token).blank?
debug ">>>>>>> Fixing access_token with refresh_token! token=#{token.inspect}"
access_token = API.get_access_token_from_refresh_token(token)
write('access_token' => access_token)
end
access_token
end
end # module Config
class ThermostatState
attr_reader :thermostat
def initialize(thermostat)
raise 'thermostat is required to be a Hash' unless thermostat.is_a?(Hash) && thermostat.any?
@thermostat = thermostat
PP.pp thermostat, $stderr if ENV['DEBUG']
debug "name: #{name}"
debug "humidity: #{humidity}"
debug "mode: #{mode}"
debug "eco: #{eco}"
debug "status: #{status}"
debug "current_temp: #{current_temp}"
debug "cool_set_point: #{cool_set_point}"
debug "heat_set_point: #{heat_set_point}"
debug "eco_cool_set_point: #{eco_cool_set_point}"
debug "eco_heat_set_point: #{eco_heat_set_point}"
end
def units = thermostat['traits']['sdm.devices.traits.Settings']['temperatureScale'] == 'FAHRENHEIT' ? :f : :c
def name = thermostat['traits']['sdm.devices.traits.Info']['customName'] || ''
def humidity = thermostat['traits']['sdm.devices.traits.Humidity']['ambientHumidityPercent']
def mode = thermostat['traits']['sdm.devices.traits.ThermostatMode']['mode']
def eco = thermostat['traits']['sdm.devices.traits.ThermostatEco']['mode'] || ''
def status = thermostat['traits']['sdm.devices.traits.ThermostatHvac']['status']
def current_temp = thermostat['traits']['sdm.devices.traits.Temperature']['ambientTemperatureCelsius']
def cool_set_point = thermostat['traits']['sdm.devices.traits.ThermostatTemperatureSetpoint']['coolCelsius']
def heat_set_point = thermostat['traits']['sdm.devices.traits.ThermostatTemperatureSetpoint']['heatCelsius']
def eco_cool_set_point = thermostat['traits']['sdm.devices.traits.ThermostatEco']['coolCelsius']
def eco_heat_set_point = thermostat['traits']['sdm.devices.traits.ThermostatEco']['heatCelsius']
def fan = thermostat['traits']['sdm.devices.traits.Fan']['timerMode']
def format_temp(temp_c) = Config.format_temp(temp_c, units)
def fan_symbol = unicode_term? ? '𖣘 ' : ''
def cooling_symbol = unicode_term? ? '❄️ ' : ''
def heating_symbol = unicode_term? ? '🔥 ' : ''
def display_status = human_status && "- #{human_status}"
def display_current_temp = format_temp(current_temp)
def display_humidity = "| Indoor humidity #{humidity}%"
def in_range?
return false if cool_set_point && current_temp > cool_set_point + 1
return false if heat_set_point && current_temp < heat_set_point + 1
true
end
def display_eco
case eco
when 'MANUAL_ECO'
'ECO: on'
when 'OFF'
# 'ECO: auto' unless in_range?
else
debug "['traits']['sdm.devices.traits.ThermostatEco']" \
" = thermostat['traits']['sdm.devices.traits.ThermostatEco'].inspect"
raise "Unknown ['traits']['sdm.devices.traits.ThermostatEco']['mode'] = #{eco.inspect}"
end
end
def human_status
case status
when 'OFF'
"#{fan_symbol}Fan on".purple if fan == 'ON'
when 'COOLING'
"#{cooling_symbol}Cooling".blue
when 'HEATING'
"#{heating_symbol}Heating".red
else
raise "Unknown ['traits']['sdm.devices.traits.ThermostatHvac']['status'] = #{status.inspect}"
end
end
def display_mode
case mode
when 'OFF'
'Off'
when 'COOL'
'Cool'.blue
when 'HEAT'
'Heat'.red
when 'HEATCOOL'
'Auto'.orange
else
raise "Unknown ['traits']['sdm.devices.traits.ThermostatMode']['mode'] = #{mode.inspect}"
end
end
def display_mode_temp
if display_eco.to_s =~ /ECO/
cool = format_temp(eco_cool_set_point)
heat = format_temp(eco_heat_set_point)
else
cool = format_temp(cool_set_point) if cool_set_point
heat = format_temp(heat_set_point) if heat_set_point
end
case mode
when 'OFF'
nil
when 'COOL'
cool
when 'HEAT'
heat
when 'HEATCOOL'
"#{heat} - #{cool}"
else
raise "Unknown ['traits']['sdm.devices.traits.ThermostatMode']['mode'] = #{mode.inspect}"
end
end
end # class ThermostatState
module Thermostat
module_function
def weather = @weather ||= Weather.new
def read
t = ThermostatState.new(read_thermostat)
line1 = [
t.name,
t.display_current_temp,
t.display_status,
]
puts line1.compact.join(' ')
line2 = [
t.display_mode,
t.display_mode_temp,
t.display_eco,
t.display_humidity,
]
city = weather.city
temp = weather.current_temp
humid = weather.current_humid
conds = weather.current_conds
if city && temp && humid && conds
line2 << '|'
line2 << format("Weather: #{city} %s %.1f %% %s", t.format_temp(temp), humid, WMO.i_to_s(conds))
end
puts format('Setting: %s', line2.compact.join(' '))
end
def set_mode(new_mode)
thermostat = read_thermostat
device_id = thermostat['name'].split('/').last
set_thermostat_mode(device_id, new_mode)
end
def set_temp(new_temp, new_hi = nil)
thermostat = read_thermostat
device_id = thermostat['name'].split('/').last
t = ThermostatState.new(thermostat)
case t.mode
when 'COOL'
set_thermostat_cool_temp(device_id, new_temp, t.units)
when 'HEAT'
set_thermostat_heat_temp(device_id, new_temp, t.units)
when 'HEATCOOL'
set_thermostat_heatcool_temp(device_id, new_temp, new_hi, t.units)
end
end
def set_fan(duration_or_off)
debug ">>>>>>>>>>> SET FAN #{duration_or_off.inspect}"
thermostat = read_thermostat
device_id = thermostat['name'].split('/').last
change_fan(device_id, duration_or_off)
end
def read_thermostat
tries = 3
while (tries -= 1).positive?
at = Config.access_token
if at.blank?
Config.fix_access_token
at = Config.access_token
end
debug "access_token: #{at.inspect}"
result = API.get('devices', Config.project_id, at)
break if result&.[]('devices')&.count&.positive?
Config.fix_access_token
end
debug "result: #{result.inspect}"
API.grant_usage unless result['devices']
thermostat = result['devices'].find { _1['type'] == 'sdm.devices.types.THERMOSTAT' }
unless thermostat
warn 'No thermostat(s) found'
exit 1
end
thermostat
end
def f_to_c(temp_f) = (temp_f - 32) * 5.0 / 9
def any_to_c(temp, units)
case Config.units || units
when :c
temp
else # nil, :f
f_to_c(temp)
end
end
def set_thermostat_cool_temp(device_id, temp, units)
API.post(
"devices/#{device_id}:executeCommand",
Config.project_id,
Config.access_token,
'command' => 'sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool',
'params' => { 'coolCelsius' => any_to_c(temp, units) },
)
end
def set_thermostat_heat_temp(device_id, temp, units)
API.post(
"devices/#{device_id}:executeCommand",
Config.project_id,
Config.access_token,
'command' => 'sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat',
'params' => { 'heatCelsius' => any_to_c(temp, units) },
)
end
def set_thermostat_heatcool_temp(device_id, heat, cool, units)
raise 'heat temp must be lower than cool temp' unless heat < cool
API.post(
"devices/#{device_id}:executeCommand",
Config.project_id,
Config.access_token,
'command' => 'sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange',
'params' => { 'heatCelsius' => any_to_c(heat, units),
'coolCelsius' => any_to_c(cool, units) },
)
end
def valid_mode?(mode)
%w[OFF COOL HEAT HEATCOOL].include?(mode.upcase)
end
def set_thermostat_mode(device_id, mode)
raise ArgumentError, 'mode must be one of: OFF, COOL, HEAT, HEATCOOL' unless valid_mode?(mode)
API.post(
"devices/#{device_id}:executeCommand",
Config.project_id,
Config.access_token,
'command' => 'sdm.devices.commands.ThermostatMode.SetMode',
'params' => { 'mode' => mode.upcase },
)
end
def turn_fan_off(device_id)
API.post(
"devices/#{device_id}:executeCommand",
Config.project_id,
Config.access_token,
'command' => 'sdm.devices.commands.Fan.SetTimer',
'params' => { 'timerMode' => 'OFF' },
)
end
def fan_seconds(duration)
case duration
when Float
duration = duration.round
when Integer
:pass
when String
duration = duration.strip.downcase
if duration.end_with?('hr') || duration.end_with?('h')
duration = Float(duration.sub(/hr?\z/, '')) * 3600
elsif duration.end_with?('m') || duration.end_with?('min')
duration = Float(duration.sub(/m(?:in)?\z/, '')) * 60
elsif duration.end_with?('s') || duration.end_with?('sec')
duration = Float(duration.sub(/s(?:ec)?\z/, ''))
elsif duration.end_with?('d') || duration.end_with?('dy') || duration.end_with?('day')
duration = Float(duration.sub(/da?y?\z/, '')) * 86_400
elsif duration.end_with?('w') || duration.end_with?('wk') || duration.end_with?('week')
duration = Float(duration.sub(/w(?:ee)?k?\z/, '')) * 604_800
elsif duration =~ /\A\d+(\.\d+)?\z/
duration = Float(duration)
else
raise 'Invalid duration format'
end
end
raise 'Invalid duration (must be 1..43200 seconds)' unless duration >= 1 && duration <= 43_200
"#{duration}s"
end
def turn_fan_on(device_id, duration)
duration = fan_seconds(duration)
API.post(
"devices/#{device_id}:executeCommand",
Config.project_id,
Config.access_token,
'command' => 'sdm.devices.commands.Fan.SetTimer',
'params' => { 'timerMode' => 'ON',
'duration' => duration, },
)
end
def change_fan(device_id, duration_or_off)
if duration_or_off.to_s.upcase == 'OFF'
turn_fan_off(device_id)
else
turn_fan_on(device_id, duration_or_off)
end
end
end # module Thermostat
module WMO
module_function
def i_to_s(x)
case x
when 0 then 'Clear'
when 1 then 'Mostly clear'
when 2 then 'Partly cloudy'
when 3 then 'Overcast'
when 19 then 'Tornado'
when 45 then 'Foggy'
when 49 then 'Freezing fog'
when 51 then 'Light drizzle'
when 53 then 'Drizzle'
when 55 then 'Dense drizzle'
when 56 then 'Freezing light drizzle'
when 57 then 'Freezing dense drizzle'
when 61 then 'Light rain'
when 63 then 'Rain'
when 65 then 'Heavy rain'
when 66 then 'Freezing light rain'
when 67 then 'Freezing heavy rain'
when 71 then 'Light snow'
when 73 then 'Snow'
when 75 then 'Heavy snow'
when 77 then 'Snow grains'
when 79 then 'Snow ice pellets'
when 80 then 'Light rain'
when 81 then 'Rain'
when 82 then 'Violent rain'
when 85 then 'Light snow showers'
when 86 then 'Heavy snow showers'
when 95 then 'Thunderstorm'
when 96 then 'Thunderstorm with hail'
when 99 then 'Thunderstorm with damaging hail'
end
end
end
module Main
module_function
def usage
p = $PROGRAM_NAME
warn <<~USAGE
Usage: #{p} # fetch thermostat status
#{p} NN # set temperature to NN (in current units, for current mode)
#{p} cool|heat|heatcool|off # set mode
#{p} cool|heat NN # set mode and temperature
#{p} heatcool NN MM # set auto mode and temperature range
#{p} fan T # turn fan on/off
T = 1..43200 seconds or off
optional time unit: [smhdw]
USAGE
exit 1
end
def run(argv)
heat_or_cool = ->(i) { %w[HEAT COOL].include?(argv[i].upcase) }
if argv.empty?
Thermostat.read
elsif argv.size == 1 && argv[0] =~ /\A-?\d+(\.\d+)?\z/
Thermostat.set_temp(argv[0].to_f)
elsif argv.size == 1 && %w[HEAT COOL HEATCOOL OFF].include?(argv[0].upcase)
Thermostat.set_mode(argv[0])
elsif argv.size == 2 && argv[1].upcase == 'FAN'
Thermostat.set_fan(argv[0])
elsif argv.size == 2 && argv[0].upcase == 'FAN'
Thermostat.set_fan(argv[1])
elsif argv.size == 2 && heat_or_cool.call(0) && argv[1] =~ /\A-?\d+(\.\d+)?\z/
Thermostat.set_mode(argv[0])
Thermostat.set_temp(argv[1].to_f)
elsif argv.size == 2 && heat_or_cool.call(1) && argv[0] =~ /\A-?\d+(\.\d+)?\z/
Thermostat.set_mode(argv[1])
Thermostat.set_temp(argv[0].to_f)
elsif argv.size == 3 && argv.map(&:upcase).include?('HEATCOOL')
heat, cool = argv.reject { |a| a.upcase == 'HEATCOOL' }.map { Float(_1) }
Thermostat.set_mode('HEATCOOL')
Thermostat.set_temp(heat, cool)
else
usage
end
end
end # module Main
Main.run(ARGV) if __FILE__ == $PROGRAM_NAME
@skull-squadron
Copy link
Author

To do:

  • Support heatcool temperature setting mode

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment