Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions drivers/cisco/webex/cloud_xapi.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
require "placeos-driver"

class Cisco::Webex::Cloud < PlaceOS::Driver
# Discovery Information
descriptive_name "Webex Cloud xAPI"
generic_name :CloudXAPI

uri_base "https://webexapis.com"

default_settings({
cisco_client_id: "",
cisco_client_secret: "",
cisco_scopes: "spark:xapi_commands spark:xapi_statuses",
})

@credentials : String = ""
getter! authoriation : Authorization
getter! device_token : DeviceToken

@cisco_client_id : String = ""
@cisco_client_secret : String = ""
@cisco_scopes : String = ""

def on_update
@cisco_client_id = setting(String, :cisco_client_id)
@cisco_client_secret = setting(String, :cisco_client_secret)
@cisco_scopes = setting?(String, :cisco_scopes) || "spark:xapi_commands spark:xapi_statuses"
@credentials = Base64.strict_encode("#{@cisco_client_id}:#{@cisco_client_secret}")

transport.before_request do |req|
unless req.path.in?("/v1/device/authorize", "/v1/device/token", "/v1/device/access_token")
access_token = get_access_token(@cisco_client_id, @cisco_client_secret)
req.headers["Authorization"] = access_token
req.headers["Content-Type"] = "application/json"
req.headers["Accept"] = "application/json"
end
logger.debug { "requesting #{req.method} #{req.path}?#{req.query}\n#{req.headers}\n#{req.body}" }
end
end

def authorize : String
@authoriation = authorize(@cisco_client_id, @cisco_scopes)
authoriation.verification_uri_complete
end

def led_colour?(device_id : String)
status(device_id, "UserInterface.LedControl.Color")
end

def led_colour(device_id : String, colour : Colour)
payload = {
"deviceId" => device_id,
"arguments" => {
"Color": colour.to_s,
},
}
command("UserInterface.LedControl.Color.Set", payload.to_json)
end

def status(device_id : String, name : String)
query = URI::Params.build do |form|
form.add("deviceId", device_id)
form.add("name", name)
end

response = get("/v1/xapi/status?#{query}")
raise "failed to query status for device #{device_id}, code #{response.status_code}" unless response.success?
JSON.parse(response.body)
end

def command(name : String, payload : String)
response = post("/v1/xapi/command/#{name}", body: payload)
raise "failed to execute command #{name}, code #{response.status_code}" unless response.success?
JSON.parse(response.body)
end

# https://developer.webex.com/docs/login-with-webex#getting-an-access-token-with-device-grant-flow
protected def get_access_token(client_id, client_secret)
raise "complete authorization process by visiting the url returned via driver :authorize method" if @authoriation.nil?

if device_token?
return device_token.auth_token if 1.minute.from_now < device_token.expiry
return refresh_token(client_id, client_secret) if 1.minute.from_now < device_token.refresh_expiry
end

# Minimum amount of time in seconds we should wait before polling device token endpoint
sleep authoriation.interval.seconds

body = URI::Params.build do |form|
form.add("client_id", client_id)
form.add("device_code", authoriation.device_code)
form.add("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
end

headers = HTTP::Headers{
"Authorization" => "Basic #{@credentials}",
"Content-Type" => "application/x-www-form-urlencoded",
"Accept" => "application/json",
}
response = post("/v1/device/token", headers: headers, body: body)
raise "failed to get device access token for client-id #{client_id}, code #{response.status_code}, body #{response.body}" unless response.success?
@device_token = DeviceToken.from_json(response.body)
device_token.auth_token
end

protected def authorize(client_id : String, scope : String) : Authorization
body = URI::Params.build do |form|
form.add("client_id", client_id)
form.add("scope", scope)
end
headers = HTTP::Headers{
"Content-Type" => "application/x-www-form-urlencoded",
"Accept" => "application/json",
}
response = post("/v1/device/authorize", headers: headers, body: body)
raise "failed to authorize client-id #{client_id}, code #{response.status_code}, body #{response.body}" unless response.success?
Authorization.from_json(response.body)
end

protected def refresh_token(client_id : String, client_secret : String)
body = URI::Params.build do |form|
form.add("grant_type", "refresh_token")
form.add("client_id", client_id)
form.add("client_secret", client_secret)
form.add("refresh_token", device_token.refresh_token)
end

headers = HTTP::Headers{
"Content-Type" => "application/x-www-form-urlencoded",
"Accept" => "application/json",
}
response = post("/v1/device/access_token", headers: headers, body: body)
raise "failed to refresh device access token for client-id #{client_id}, code #{response.status_code}, body #{response.body}" unless response.success?
@device_token = DeviceToken.from_json(response.body)
device_token.auth_token
end

enum Colour
Green
Yellow
Red
Purple
Blue
Orange
Orchid
Aquamarine
Fuchsia
Violet
Magenta
Scarlet
Gold
Lime
Turquoise
Cyan
Off
end

record Authorization, device_code : String, expires_in : Int64, user_code : String, verification_url : String?,
verification_uri_complete : String, interval : Int64 do
include JSON::Serializable

@[JSON::Field(ignore: true)]
getter! expiry : Time

def after_initialize
@expiry = Time.utc + expires_in.seconds
end
end

record DeviceToken, scope : String, expires_in : Int64, token_type : String, refresh_token : String, refresh_token_expires_in : Int64,
access_token : String do
include JSON::Serializable

@[JSON::Field(ignore: true)]
getter! expiry : Time

@[JSON::Field(ignore: true)]
getter! refresh_expiry : Time

def after_initialize
@expiry = Time.utc + expires_in.seconds
@refresh_expiry = Time.utc + refresh_token_expires_in.seconds
end

def auth_token
"#{token_type} #{access_token}"
end
end
end
96 changes: 96 additions & 0 deletions drivers/cisco/webex/cloud_xapi_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
require "placeos-driver/spec"

DriverSpecs.mock_driver "Cisco::Webex::Cloud" do
settings({
cisco_client_id: "client-id",
cisco_client_secret: "client-secret",
})

ret_val = exec(:authorize)

expect_http_request do |request, response|
if request.path == "/v1/device/authorize"
response.status_code = 200
response << auth_resp_json.to_json
else
response.status_code = 401
end
end

ret_val.get.should eq("https://oauth-helper-r.wbx2.com/verify?userCode=6587b053970a656c29500e6bced0c1c59290a743ad7e34af474a65085860de57")

ret_val = exec(:led_colour?, "device1-id")

expect_http_request(2.seconds) do |request, response|
if request.path == "/v1/device/token"
response.status_code = 200
response << device_resp_json.to_json
else
response.status_code = 401
end
end

expect_http_request(2.seconds) do |request, response|
if request.headers["Authorization"]? == "Bearer generated-access-token"
response.status_code = 200
response << color_resp(request.query_params["deviceId"]).to_json
else
response.status_code = 401
end
end

ret_val.get.should eq(color_resp("device1-id"))

ret_val = exec(:led_colour, "device1-id", :green)

# invoking another endpoint request should use previously obtained access token

expect_http_request do |request, response|
headers = request.headers
io = request.body
if io
data = io.gets_to_end
request = JSON.parse(data)
if request["deviceId"] == "device1-id" && request["arguments"]["Color"] == "Green" && headers["Authorization"] == "Bearer generated-access-token"
response.status_code = 202
response << color_set_resp.to_json
else
response.status_code = 401
end
else
raise "expected request to include excute command body params #{request.inspect}"
end
end

ret_val.get.should eq(color_set_resp)
end

def color_resp(device_id : String)
{"deviceId" => device_id, "result" => {"LedControl" => {"Color" => "Green"}}}
end

def color_set_resp
{"deviceId" => "device1-id", "arguments" => {"Color" => "Green"}}
end

def auth_resp_json
{
"device_code": "5d5cf602-f0dd-49d5-bfd3-915267e4fbe0",
"expires_in": 300,
"user_code": "729703",
"verification_uri": "https://oauth-helper-r.wbx2.com/verify",
"verification_uri_complete": "https://oauth-helper-r.wbx2.com/verify?userCode=6587b053970a656c29500e6bced0c1c59290a743ad7e34af474a65085860de57",
"interval": 1,
}
end

def device_resp_json
{
"scope": "meeting:schedules_read",
"expires_in": 64799,
"token_type": "Bearer",
"refresh_token": "MjZmMzcyZWUtMzI2MS00MmE4LTgyZWMtYTVlMWIxYzBjZjhiODJmYzViOTItMGFi_PF84_1eb65fdf-9643-417f-9974-ad72cae0e10f",
"access_token": "generated-access-token",
"refresh_token_expires_in": 7697037,
}
end