SureFlap Pet Status

Show inside/outside status of pets using a SureFlap smart cat flap or pet door. Can also show notifications.

Image preview of SureFlap Pet Status plugin.

sureflap.15s.rb

Edit
Open on GitHub
#!/usr/bin/env ruby

# <xbar.title>SureFlap Pet Status</xbar.title>
# <xbar.version>v1.2.0</xbar.version>
# <xbar.author>Henrik Nyh</xbar.author>
# <xbar.author.github>henrik</xbar.author.github>
# <xbar.desc>Show inside/outside status of pets using a SureFlap smart cat flap or pet door. Can also show notifications.</xbar.desc>
# <xbar.dependencies>ruby</xbar.dependencies>

# By Henrik Nyh <https://henrik.nyh.se> 2019-12-16 under the MIT license.
# Heavily based on the https://github.com/alextoft/sureflap PHP code by Alex Toft.
#
# Has no dependencies outside the Ruby standard library (uses Net::HTTP directly and painfully).


# NOTE: You can configure these if you like:

# You can exclude e.g. indoor-only pets from the menu bar by listing their names here. (But all pets show if you click the menu bar item.)
# Example: [ "Foocat", "Bardog" ]
HIDE_PETS_IN_MENU_BAR = [ ]

# You can ignore pets entirely by listing their names here. They won't be listed anywhere, and no notifications sent.
# Example: [ "Foocat", "Bardog" ]
IGNORE_PETS_ENTIRELY = [ ]

# Show a notification when in/out state changes?
NOTIFICATIONS = true

# Increase this number when you add 😻, remove 😿 or rename pets or flaps/doors. You don't need to increase it if you just change the configuration above.
# As long as this number remains unchanged, we will assume nothing's changed, which makes things faster and means we can check status more frequently.
CACHE_VERSION = 1

# End of configuration.


require "net/http"
require "json"
require "pp"
require "time"
require "fileutils"
require "digest"

ENDPOINT = "https://app.api.surehub.io"
TOKEN_PATH = File.expand_path("~/.sureflap_token")
AUTH_PATH = File.expand_path("~/.sureflap_auth")

unless File.exist?(AUTH_PATH)
  puts ":warning: Run: echo \"[email protected] / my_pw\" > ~/.sureflap_auth"
  exit
end

EMAIL, PASSWORD = File.read(AUTH_PATH).strip.split(" / ")
AUTH_DATA = { email_address: EMAIL, password: PASSWORD, device_id: "0" }

# From https://github.com/barsoom/net_http_timeout_errors/blob/master/lib/net_http_timeout_errors.rb
NETWORK_ERRORS = [
  EOFError,
  Errno::ECONNREFUSED,
  Errno::ECONNRESET,
  Errno::EHOSTUNREACH,
  Errno::EINVAL,
  Errno::ENETUNREACH,
  Errno::EPIPE,
  Errno::ETIMEDOUT,
  Net::HTTPBadResponse,
  Net::HTTPHeaderSyntaxError,
  Net::ProtocolError,
  Net::ReadTimeout,
  SocketError,
  Timeout::Error,  # Also covers subclasses like Net::OpenTimeout.
]

class StaleTokenError < StandardError; end

def handle_network_errors
  yield
rescue *NETWORK_ERRORS => e
  puts "πŸ™€"
  puts "---"
  puts "Network error when trying to communicate with the SureFlap API!"
  puts "Check that you're not offline."
  puts "---"
  puts "Technical details:"
  puts "#{e.class.name}: #{e.message}"
  exit 1
end

def post(path, data)
  handle_network_errors do
    uri = URI.join(ENDPOINT, path)
    req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
    req.body = data.to_json

    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
    hash = JSON.parse(res.body)

    raise "HTTP error!\n#{res.code} #{res.message}\n#{hash.pretty_inspect}" unless res.code == "200"

    hash
  end
end

def get(path, token:, cache:)
  if cache
    cache_file = "/tmp/sureflap_#{Digest::SHA256.hexdigest("#{path}-#{token}")}_v#{CACHE_VERSION}"
    return JSON.parse(File.read(cache_file)) if File.exist?(cache_file)
  end

  handle_network_errors do
    uri = URI.join(ENDPOINT, path)
    req = Net::HTTP::Get.new(uri,
      "Content-Type" => "application/json",
      "Authorization" => "Bearer #{token}",
    )

    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
    raw_json = res.body
    hash = JSON.parse(raw_json)

    error_message = "HTTP error!\n#{res.code} #{res.message}\n#{hash.pretty_inspect}" unless res.code == "200"

    if res.code == "401" && hash.dig("error", "message") == [ "Token Signature could not be verified." ]
      raise StaleTokenError, error_message
    elsif res.code != "200"
      raise error_message
    end

    File.write(cache_file, raw_json) if cache
    hash
  end
end

def refresh_token
  token = post("/api/auth/login", AUTH_DATA).dig("data", "token")
  File.write(TOKEN_PATH, token)
  token
end

# This method reuses an existing token until it becomes stale.
def with_fresh_token
  retried = false

  begin
    token = File.exist?(TOKEN_PATH) && File.read(TOKEN_PATH) || refresh_token()
    yield(token)
  rescue StaleTokenError
    raise if retried  # Avoid endless loops.
    retried = true

    FileUtils.rm(TOKEN_PATH)
    retry
  end
end

with_fresh_token do |token|
  # We assume a single household.
  household_id = get("/api/household", token: token, cache: true).dig("data", 0, "id")

  data =
    get("/api/household/#{household_id}/pet", token: token, cache: true).fetch("data").map { |pet_data|
      id = pet_data.fetch("id")
      name = pet_data.fetch("name")
      next if IGNORE_PETS_ENTIRELY.include?(name)

      position_data = get("/api/pet/#{id}/position", token: token, cache: false).fetch("data")

      is_inside = (position_data.fetch("where") == 1)
      since = Time.parse(position_data.fetch("since")).localtime  # Convert from UTC to local time.

      [ name, [ id, is_inside, since ] ]
    }.compact.to_h

  pets_in_summary = data.keys - HIDE_PETS_IN_MENU_BAR - IGNORE_PETS_ENTIRELY
  raise "There are no pets to summarize!" if pets_in_summary.empty?

  icon = ->(is_inside) { is_inside ? "🏠" : "🌳" }

  puts pets_in_summary.map { |name|
    _id, is_inside, _since = data.fetch(name)
    "#{icon.(is_inside)} #{name}"
  }.join("  ")

  puts "---"

  today = Date.today
  data.each do |name, (id, is_inside, since)|
    if NOTIFICATIONS
      inside_state_path = "/tmp/sureflap_#{id}_is_inside"
      previous_is_inside_string = File.read(inside_state_path) rescue nil

      if previous_is_inside_string && previous_is_inside_string != is_inside.to_s
        system("osascript", "-e", %{display notification "#{icon.(is_inside)} #{name} #{is_inside ? "has entered… Hi #{name}!" : "has left… Bye #{name}!" }" with title "Cat flap"})
      end

      File.write(inside_state_path, is_inside)
    end

    formatting_string =
      case since.to_date
      when today then "%H:%M"
      when today - 1 then "yesterday at %H:%M"
      when (today - 2)..(today - 6) then "%a at %H:%M"
      else "%b %-d %Y at %H:%M"
      end
    puts "#{icon.(is_inside)} #{name} is #{is_inside ? "inside" : "outside"} since #{since.strftime(formatting_string)}."
  end
end