From 4024d68e865e2300e48ce9853e43c8d717809a83 Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Mon, 17 Feb 2025 14:36:56 +0800 Subject: [PATCH 1/2] feat(cloud_xapi): PPT-1818 added webx cloud xapi driver --- drivers/cisco/webex/cloud_xapi.cr | 192 +++++++++++++++++++++++++ drivers/cisco/webex/cloud_xapi_spec.cr | 97 +++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 drivers/cisco/webex/cloud_xapi.cr create mode 100644 drivers/cisco/webex/cloud_xapi_spec.cr diff --git a/drivers/cisco/webex/cloud_xapi.cr b/drivers/cisco/webex/cloud_xapi.cr new file mode 100644 index 00000000000..4aa2ffd0bbf --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi.cr @@ -0,0 +1,192 @@ +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", + cisco_device_id: "", + }) + + @device_id : String = "" + @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" + @device_id = setting(String, :cisco_device_id) + @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? + status("UserInterface.LedControl.Color") + end + + def led_colour(colour : Colour) + payload = { + "deviceId" => @device_id, + "arguments" => { + "Color": colour.to_s, + }, + } + command("UserInterface.LedControl.Color.Set", payload.to_json) + end + + def status(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 diff --git a/drivers/cisco/webex/cloud_xapi_spec.cr b/drivers/cisco/webex/cloud_xapi_spec.cr new file mode 100644 index 00000000000..47b6e481e87 --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi_spec.cr @@ -0,0 +1,97 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Webex::Cloud" do + settings({ + cisco_client_id: "client-id", + cisco_client_secret: "client-secret", + cisco_device_id: "device1-id", + }) + + 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?) + + 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, :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 From b059269ab256bcbca6fc15734ee00f3a52e79bee Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Thu, 27 Feb 2025 13:56:52 +0800 Subject: [PATCH 2/2] refactor: [PPT-1818] methods to accept device-id param --- drivers/cisco/webex/cloud_xapi.cr | 17 +++++++---------- drivers/cisco/webex/cloud_xapi_spec.cr | 5 ++--- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/drivers/cisco/webex/cloud_xapi.cr b/drivers/cisco/webex/cloud_xapi.cr index 4aa2ffd0bbf..902637f658b 100644 --- a/drivers/cisco/webex/cloud_xapi.cr +++ b/drivers/cisco/webex/cloud_xapi.cr @@ -11,10 +11,8 @@ class Cisco::Webex::Cloud < PlaceOS::Driver cisco_client_id: "", cisco_client_secret: "", cisco_scopes: "spark:xapi_commands spark:xapi_statuses", - cisco_device_id: "", }) - @device_id : String = "" @credentials : String = "" getter! authoriation : Authorization getter! device_token : DeviceToken @@ -27,7 +25,6 @@ class Cisco::Webex::Cloud < PlaceOS::Driver @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" - @device_id = setting(String, :cisco_device_id) @credentials = Base64.strict_encode("#{@cisco_client_id}:#{@cisco_client_secret}") transport.before_request do |req| @@ -46,13 +43,13 @@ class Cisco::Webex::Cloud < PlaceOS::Driver authoriation.verification_uri_complete end - def led_colour? - status("UserInterface.LedControl.Color") + def led_colour?(device_id : String) + status(device_id, "UserInterface.LedControl.Color") end - def led_colour(colour : Colour) + def led_colour(device_id : String, colour : Colour) payload = { - "deviceId" => @device_id, + "deviceId" => device_id, "arguments" => { "Color": colour.to_s, }, @@ -60,14 +57,14 @@ class Cisco::Webex::Cloud < PlaceOS::Driver command("UserInterface.LedControl.Color.Set", payload.to_json) end - def status(name : String) + def status(device_id : String, name : String) query = URI::Params.build do |form| - form.add("deviceId", @device_id) + 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? + raise "failed to query status for device #{device_id}, code #{response.status_code}" unless response.success? JSON.parse(response.body) end diff --git a/drivers/cisco/webex/cloud_xapi_spec.cr b/drivers/cisco/webex/cloud_xapi_spec.cr index 47b6e481e87..b7bf6ed5aed 100644 --- a/drivers/cisco/webex/cloud_xapi_spec.cr +++ b/drivers/cisco/webex/cloud_xapi_spec.cr @@ -4,7 +4,6 @@ DriverSpecs.mock_driver "Cisco::Webex::Cloud" do settings({ cisco_client_id: "client-id", cisco_client_secret: "client-secret", - cisco_device_id: "device1-id", }) ret_val = exec(:authorize) @@ -20,7 +19,7 @@ DriverSpecs.mock_driver "Cisco::Webex::Cloud" do ret_val.get.should eq("https://oauth-helper-r.wbx2.com/verify?userCode=6587b053970a656c29500e6bced0c1c59290a743ad7e34af474a65085860de57") - ret_val = exec(:led_colour?) + ret_val = exec(:led_colour?, "device1-id") expect_http_request(2.seconds) do |request, response| if request.path == "/v1/device/token" @@ -42,7 +41,7 @@ DriverSpecs.mock_driver "Cisco::Webex::Cloud" do ret_val.get.should eq(color_resp("device1-id")) - ret_val = exec(:led_colour, :green) + ret_val = exec(:led_colour, "device1-id", :green) # invoking another endpoint request should use previously obtained access token