-
-
Save skull-squadron/b7035b9c6ed5962374774df08851bda2 to your computer and use it in GitHub Desktop.
Nest Thermostat CLI - in Ruby, primitive but functional
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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