Skip to content

Instantly share code, notes, and snippets.

@skull-squadron
Last active October 18, 2025 20:28
Show Gist options
  • Save skull-squadron/b7035b9c6ed5962374774df08851bda2 to your computer and use it in GitHub Desktop.
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 res.class
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 "GET #{uri}"
headers = { 'Content-Type' => 'application/json' }.merge(headers)
debug "request headers #{headers.inspect}"
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 "POST #{uri}"
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) { |req| req.body = body.to_s if body }
def post_form(uri, form_data = {}, headers = {}) = generic_post(uri, headers) { |req| req.form_data = form_data }
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
lat = geocoder.lat
long = geocoder.long
return unless lat && long
json = Http.get(
"#{ENDPOINT}/forecast",
'latitude' => lat,
'longitude' => long,
'current' => 'temperature_2m'
)
debug json.inspect
json&.[]('current')&.[]('temperature_2m')
end
private
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,
]
if (city = weather.city) && (temp = weather.current_temp)
line2 << '|'
line2 << format("Weather: #{city} %s", t.format_temp(temp))
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.SetCool',
'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 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment