mirror of
https://github.com/netfun2000/hipudding-teslamate.git
synced 2026-02-27 09:44:28 +08:00
Include customized :tesla_api package
This commit is contained in:
20
coveralls.json
Normal file
20
coveralls.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"coverage_options": {
|
||||
"treat_no_relevant_lines_as_covered": true,
|
||||
"output_dir": "cover/",
|
||||
"minimum_coverage": 0
|
||||
},
|
||||
|
||||
"terminal_options": {
|
||||
"file_column_width": 40
|
||||
},
|
||||
|
||||
"skip_files": [
|
||||
"lib/tesla_api",
|
||||
"lib/teslamate/release.ex",
|
||||
"lib/teslamate/application.ex",
|
||||
"lib/teslamate/custom_expressions.ex",
|
||||
"lib/teslamate_web.ex"
|
||||
]
|
||||
}
|
||||
|
||||
81
lib/tesla_api.ex
Normal file
81
lib/tesla_api.ex
Normal file
@@ -0,0 +1,81 @@
|
||||
defmodule TeslaApi do
|
||||
alias Mojito.Response, as: Res
|
||||
alias Mojito.Error, as: Err
|
||||
alias __MODULE__.Error
|
||||
|
||||
@base_url URI.parse("https://owner-api.teslamotors.com/")
|
||||
@user_agent "github.com/adriankumpf/teslamate"
|
||||
@timeout 60_000
|
||||
|
||||
def get(path, token, opts \\ []) when is_binary(token) do
|
||||
headers = [{"user-agent", @user_agent}, {"Authorization", "Bearer " <> token}]
|
||||
|
||||
case Mojito.get(url(path), headers, timeout: @timeout) do
|
||||
{:ok, %Res{} = response} ->
|
||||
case decode_body(response) do
|
||||
%Res{complete: false} = env ->
|
||||
{:error, %Error{reason: :incomplete_response, env: env}}
|
||||
|
||||
%Res{status_code: status, body: %{"response" => res}} when status in 200..299 ->
|
||||
transform = Keyword.get(opts, :transform, & &1)
|
||||
{:ok, if(is_list(res), do: Enum.map(res, transform), else: transform.(res))}
|
||||
|
||||
%Res{status_code: 401} = env ->
|
||||
{:error, %Error{reason: :unauthorized, env: env}}
|
||||
|
||||
%Res{status_code: 404, body: %{"error" => "not_found"}} = env ->
|
||||
{:error, %Error{reason: :vehicle_not_found, env: env}}
|
||||
|
||||
%Res{status_code: 405, body: %{"error" => "vehicle is curently in service"}} = env ->
|
||||
{:error, %Error{reason: :vehicle_in_service, env: env}}
|
||||
|
||||
%Res{status_code: 408, body: %{"error" => "vehicle unavailable:" <> _}} = env ->
|
||||
{:error, %Error{reason: :vehicle_unavailable, env: env}}
|
||||
|
||||
%Res{status_code: 504} = env ->
|
||||
{:error, %Error{reason: :timeout, env: env}}
|
||||
|
||||
%Res{status_code: status, body: %{"error" => msg}} = env when status >= 500 ->
|
||||
{:error, %Error{reason: :unknown, message: msg, env: env}}
|
||||
|
||||
%Res{body: body} = env ->
|
||||
{:error, %Error{reason: :unknown, message: inspect(body), env: env}}
|
||||
end
|
||||
|
||||
{:error, %Err{reason: reason, message: msg}} ->
|
||||
{:error, %Error{reason: reason, message: msg || "An unknown error has occurred"}}
|
||||
end
|
||||
end
|
||||
|
||||
def post(path, token, params) when is_map(params) do
|
||||
body = Jason.encode!(params)
|
||||
|
||||
headers = [
|
||||
{"user-agent", @user_agent},
|
||||
{"content-type", "application/json"}
|
||||
| if(is_nil(token), do: [], else: [{"Authorization", "Bearer " <> token}])
|
||||
]
|
||||
|
||||
with {:ok, response} <- path |> url() |> Mojito.post(headers, body, timeout: @timeout) do
|
||||
{:ok, decode_body(response)}
|
||||
end
|
||||
end
|
||||
|
||||
## Private
|
||||
|
||||
defp url(path) do
|
||||
@base_url
|
||||
|> Map.put(:path, path)
|
||||
|> URI.to_string()
|
||||
end
|
||||
|
||||
defp decode_body(%Res{body: body} = response) do
|
||||
body =
|
||||
case Jason.decode(body) do
|
||||
{:ok, decoded_body} -> decoded_body
|
||||
{:error, _reason} -> body
|
||||
end
|
||||
|
||||
Map.put(response, :body, body)
|
||||
end
|
||||
end
|
||||
78
lib/tesla_api/auth.ex
Normal file
78
lib/tesla_api/auth.ex
Normal file
@@ -0,0 +1,78 @@
|
||||
defmodule TeslaApi.Auth do
|
||||
import TeslaApi
|
||||
|
||||
alias TeslaApi.{Auth, Error}
|
||||
|
||||
defstruct [:token, :type, :expires_in, :refresh_token, :created_at]
|
||||
|
||||
@client_id "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384"
|
||||
@client_secret "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3"
|
||||
|
||||
def login(email, password) do
|
||||
post("/oauth/token", nil, %{
|
||||
"grant_type" => "password",
|
||||
"client_id" => @client_id,
|
||||
"client_secret" => @client_secret,
|
||||
"email" => email,
|
||||
"password" => password
|
||||
})
|
||||
|> handle_response()
|
||||
end
|
||||
|
||||
def refresh(%Auth{token: token, refresh_token: refresh_token}) do
|
||||
post("/oauth/token", token, %{
|
||||
"grant_type" => "refresh_token",
|
||||
"client_id" => @client_id,
|
||||
"client_secret" => @client_secret,
|
||||
"refresh_token" => refresh_token
|
||||
})
|
||||
|> handle_response()
|
||||
end
|
||||
|
||||
def revoke(%Auth{token: token}) do
|
||||
post("/oauth/revoke", token, %{"token" => token})
|
||||
|> handle_response()
|
||||
end
|
||||
|
||||
defp handle_response(response) do
|
||||
case response do
|
||||
{:ok, %Mojito.Response{status_code: 200, body: body}} when body == %{} ->
|
||||
:ok
|
||||
|
||||
{:ok, %Mojito.Response{status_code: 200, body: %{"response" => true}}} ->
|
||||
:ok
|
||||
|
||||
{:ok, %Mojito.Response{status_code: 200, body: body}} when is_map(body) ->
|
||||
auth = %__MODULE__{
|
||||
token: body["access_token"],
|
||||
type: body["token_type"],
|
||||
expires_in: body["expires_in"],
|
||||
refresh_token: body["refresh_token"],
|
||||
created_at: body["created_at"]
|
||||
}
|
||||
|
||||
{:ok, auth}
|
||||
|
||||
{:ok, %Mojito.Response{status_code: 401} = e} ->
|
||||
error = %Error{
|
||||
reason: :authentication_failure,
|
||||
message: "Failed to authenticate.",
|
||||
env: e
|
||||
}
|
||||
|
||||
{:error, error}
|
||||
|
||||
{:ok, %Mojito.Response{} = e} ->
|
||||
{:error, %Error{reason: :unknown, message: "An unknown error has occurred.", env: e}}
|
||||
|
||||
{:error, %Mojito.Error{reason: reason} = e} ->
|
||||
error = %Error{
|
||||
reason: :unknown,
|
||||
message: "An unknown error has occurred: #{inspect(reason)}",
|
||||
env: e
|
||||
}
|
||||
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
4
lib/tesla_api/error.ex
Normal file
4
lib/tesla_api/error.ex
Normal file
@@ -0,0 +1,4 @@
|
||||
defmodule TeslaApi.Error do
|
||||
@enforce_keys [:reason]
|
||||
defstruct [:reason, :message, :env]
|
||||
end
|
||||
59
lib/tesla_api/vehicle.ex
Normal file
59
lib/tesla_api/vehicle.ex
Normal file
@@ -0,0 +1,59 @@
|
||||
defmodule TeslaApi.Vehicle do
|
||||
alias __MODULE__.State.{Charge, Climate, Drive, VehicleConfig, VehicleState}
|
||||
alias TeslaApi.Auth
|
||||
|
||||
defstruct id: nil,
|
||||
vehicle_id: nil,
|
||||
vin: nil,
|
||||
tokens: [],
|
||||
state: "unknown",
|
||||
option_codes: [],
|
||||
in_service: false,
|
||||
display_name: nil,
|
||||
color: nil,
|
||||
calendar_enabled: nil,
|
||||
backseat_token: nil,
|
||||
backseat_token_updated_at: nil,
|
||||
api_version: nil,
|
||||
charge_state: nil,
|
||||
climate_state: nil,
|
||||
drive_state: nil,
|
||||
gui_settings: nil,
|
||||
vehicle_config: nil,
|
||||
vehicle_state: nil
|
||||
|
||||
def list(%Auth{token: token}) do
|
||||
TeslaApi.get("/api/1/vehicles", token, transform: &vehicle/1)
|
||||
end
|
||||
|
||||
def get(%Auth{token: token}, id) do
|
||||
TeslaApi.get("/api/1/vehicles/#{id}", token, transform: &vehicle/1)
|
||||
end
|
||||
|
||||
def get_with_state(%Auth{token: token}, id) do
|
||||
TeslaApi.get("/api/1/vehicles/#{id}/vehicle_data", token, transform: &vehicle/1)
|
||||
end
|
||||
|
||||
defp vehicle(v) do
|
||||
%__MODULE__{
|
||||
id: v["id"],
|
||||
vehicle_id: v["vehicle_id"],
|
||||
vin: v["vin"],
|
||||
tokens: v["tokens"],
|
||||
state: v["state"] || "unknown",
|
||||
option_codes: String.split(v["option_codes"] || "", ","),
|
||||
in_service: v["in_service"],
|
||||
display_name: v["display_name"],
|
||||
color: v["color"],
|
||||
calendar_enabled: v["calendar_enabled"],
|
||||
backseat_token: v["backseat_token"],
|
||||
backseat_token_updated_at: v["backseat_token_updated_at"],
|
||||
api_version: v["api_version"],
|
||||
charge_state: if(v["charge_state"], do: Charge.result(v["charge_state"])),
|
||||
climate_state: if(v["climate_state"], do: Climate.result(v["climate_state"])),
|
||||
drive_state: if(v["drive_state"], do: Drive.result(v["drive_state"])),
|
||||
vehicle_config: if(v["vehicle_config"], do: VehicleConfig.result(v["vehicle_config"])),
|
||||
vehicle_state: if(v["vehicle_state"], do: VehicleState.result(v["vehicle_state"]))
|
||||
}
|
||||
end
|
||||
end
|
||||
365
lib/tesla_api/vehicle/state.ex
Normal file
365
lib/tesla_api/vehicle/state.ex
Normal file
@@ -0,0 +1,365 @@
|
||||
defmodule TeslaApi.Vehicle.State do
|
||||
defmodule Charge do
|
||||
defstruct [
|
||||
:charge_miles_added_rated,
|
||||
:charge_current_request,
|
||||
:charger_power,
|
||||
:managed_charging_start_time,
|
||||
:charger_phases,
|
||||
:charge_energy_added,
|
||||
:charger_voltage,
|
||||
:fast_charger_type,
|
||||
:time_to_full_charge,
|
||||
:ideal_battery_range,
|
||||
:usable_battery_level,
|
||||
:scheduled_charging_pending,
|
||||
:charger_actual_current,
|
||||
:est_battery_range,
|
||||
:charge_limit_soc_min,
|
||||
:charge_port_door_open,
|
||||
:managed_charging_active,
|
||||
:charge_limit_soc_max,
|
||||
:fast_charger_present,
|
||||
:fast_charger_brand,
|
||||
:scheduled_charging_start_time,
|
||||
:conn_charge_cable,
|
||||
:timestamp,
|
||||
:user_charge_enable_request,
|
||||
:charge_port_cold_weather_mode,
|
||||
:charge_to_max_range,
|
||||
:max_range_charge_counter,
|
||||
:charge_limit_soc_std,
|
||||
:charge_port_latch,
|
||||
:managed_charging_user_canceled,
|
||||
:charger_pilot_current,
|
||||
:trip_charging,
|
||||
:battery_range,
|
||||
:charging_state,
|
||||
:charge_rate,
|
||||
:not_enough_power_to_heat,
|
||||
:charge_limit_soc,
|
||||
:charge_enable_request,
|
||||
:charge_current_request_max,
|
||||
:battery_level,
|
||||
:charge_miles_added_ideal,
|
||||
:battery_heater_on
|
||||
]
|
||||
|
||||
def result(charge) when is_map(charge) do
|
||||
%__MODULE__{
|
||||
charge_miles_added_rated: charge["charge_miles_added_rated"],
|
||||
charge_current_request: charge["charge_current_request"],
|
||||
charger_power: charge["charger_power"],
|
||||
managed_charging_start_time: charge["managed_charging_start_time"],
|
||||
charger_phases: charge["charger_phases"],
|
||||
charge_energy_added: charge["charge_energy_added"],
|
||||
charger_voltage: charge["charger_voltage"],
|
||||
fast_charger_type: charge["fast_charger_type"],
|
||||
time_to_full_charge: charge["time_to_full_charge"],
|
||||
ideal_battery_range: charge["ideal_battery_range"],
|
||||
usable_battery_level: charge["usable_battery_level"],
|
||||
scheduled_charging_pending: charge["scheduled_charging_pending"],
|
||||
charger_actual_current: charge["charger_actual_current"],
|
||||
est_battery_range: charge["est_battery_range"],
|
||||
charge_limit_soc_min: charge["charge_limit_soc_min"],
|
||||
charge_port_door_open: charge["charge_port_door_open"],
|
||||
managed_charging_active: charge["managed_charging_active"],
|
||||
charge_limit_soc_max: charge["charge_limit_soc_max"],
|
||||
fast_charger_present: charge["fast_charger_present"],
|
||||
fast_charger_brand: charge["fast_charger_brand"],
|
||||
scheduled_charging_start_time: charge["scheduled_charging_start_time"],
|
||||
conn_charge_cable: charge["conn_charge_cable"],
|
||||
timestamp: charge["timestamp"],
|
||||
user_charge_enable_request: charge["user_charge_enable_request"],
|
||||
charge_port_cold_weather_mode: charge["charge_port_cold_weather_mode"],
|
||||
charge_to_max_range: charge["charge_to_max_range"],
|
||||
max_range_charge_counter: charge["max_range_charge_counter"],
|
||||
charge_limit_soc_std: charge["charge_limit_soc_std"],
|
||||
charge_port_latch: charge["charge_port_latch"],
|
||||
managed_charging_user_canceled: charge["managed_charging_user_canceled"],
|
||||
charger_pilot_current: charge["charger_pilot_current"],
|
||||
trip_charging: charge["trip_charging"],
|
||||
battery_range: charge["battery_range"],
|
||||
charging_state: charge["charging_state"],
|
||||
charge_rate: charge["charge_rate"],
|
||||
not_enough_power_to_heat: charge["not_enough_power_to_heat"],
|
||||
charge_limit_soc: charge["charge_limit_soc"],
|
||||
charge_enable_request: charge["charge_enable_request"],
|
||||
charge_current_request_max: charge["charge_current_request_max"],
|
||||
battery_level: charge["battery_level"],
|
||||
charge_miles_added_ideal: charge["charge_miles_added_ideal"],
|
||||
battery_heater_on: charge["battery_heater_on"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Climate do
|
||||
defstruct [
|
||||
:battery_heater,
|
||||
:battery_heater_no_power,
|
||||
:climate_keeper_mode,
|
||||
:defrost_mode,
|
||||
:driver_temp_setting,
|
||||
:fan_status,
|
||||
:inside_temp,
|
||||
:is_auto_conditioning_on,
|
||||
:is_climate_on,
|
||||
:is_front_defroster_on,
|
||||
:is_preconditioning,
|
||||
:is_rear_defroster_on,
|
||||
:left_temp_direction,
|
||||
:max_avail_temp,
|
||||
:min_avail_temp,
|
||||
:outside_temp,
|
||||
:passenger_temp_setting,
|
||||
:remote_heater_control_enabled,
|
||||
:right_temp_direction,
|
||||
:seat_heater_left,
|
||||
:seat_heater_rear_center,
|
||||
:seat_heater_rear_left,
|
||||
:seat_heater_rear_right,
|
||||
:seat_heater_rear_left_back,
|
||||
:seat_heater_rear_right_back,
|
||||
:seat_heater_right,
|
||||
:side_mirror_heaters,
|
||||
:steering_wheel_heater,
|
||||
:smart_preconditioning,
|
||||
:timestamp,
|
||||
:wiper_blade_heater
|
||||
]
|
||||
|
||||
def result(climate) when is_map(climate) do
|
||||
%__MODULE__{
|
||||
battery_heater: climate["battery_heater"],
|
||||
battery_heater_no_power: climate["battery_heater_no_power"],
|
||||
climate_keeper_mode: climate["climate_keeper_mode"],
|
||||
defrost_mode: climate["defrost_mode"],
|
||||
driver_temp_setting: climate["driver_temp_setting"],
|
||||
fan_status: climate["fan_status"],
|
||||
inside_temp: climate["inside_temp"],
|
||||
is_auto_conditioning_on: climate["is_auto_conditioning_on"],
|
||||
is_climate_on: climate["is_climate_on"],
|
||||
is_front_defroster_on: climate["is_front_defroster_on"],
|
||||
is_preconditioning: climate["is_preconditioning"],
|
||||
is_rear_defroster_on: climate["is_rear_defroster_on"],
|
||||
left_temp_direction: climate["left_temp_direction"],
|
||||
max_avail_temp: climate["max_avail_temp"],
|
||||
min_avail_temp: climate["min_avail_temp"],
|
||||
outside_temp: climate["outside_temp"],
|
||||
passenger_temp_setting: climate["passenger_temp_setting"],
|
||||
remote_heater_control_enabled: climate["remote_heater_control_enabled"],
|
||||
right_temp_direction: climate["right_temp_direction"],
|
||||
seat_heater_left: climate["seat_heater_left"],
|
||||
seat_heater_rear_center: climate["seat_heater_rear_center"],
|
||||
seat_heater_rear_left: climate["seat_heater_rear_left"],
|
||||
seat_heater_rear_right: climate["seat_heater_rear_right"],
|
||||
seat_heater_rear_left_back: climate["seat_heater_rear_left_back"],
|
||||
seat_heater_rear_right_back: climate["seat_heater_rear_right_back"],
|
||||
seat_heater_right: climate["seat_heater_right"],
|
||||
side_mirror_heaters: climate["side_mirror_heaters"],
|
||||
steering_wheel_heater: climate["steering_wheel_heater"],
|
||||
smart_preconditioning: climate["smart_preconditioning"],
|
||||
timestamp: climate["timestamp"],
|
||||
wiper_blade_heater: climate["wiper_blade_heater"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Drive do
|
||||
defstruct [
|
||||
:gps_as_of,
|
||||
:heading,
|
||||
:latitude,
|
||||
:longitude,
|
||||
:native_latitude,
|
||||
:native_location_supported,
|
||||
:native_longitude,
|
||||
:native_type,
|
||||
:power,
|
||||
:shift_state,
|
||||
:speed,
|
||||
:timestamp
|
||||
]
|
||||
|
||||
def result(drive) when is_map(drive) do
|
||||
%__MODULE__{
|
||||
gps_as_of: drive["gps_as_of"],
|
||||
heading: drive["heading"],
|
||||
latitude: drive["latitude"],
|
||||
longitude: drive["longitude"],
|
||||
native_latitude: drive["native_latitude"],
|
||||
native_location_supported: drive["native_location_supported"],
|
||||
native_longitude: drive["native_longitude"],
|
||||
native_type: drive["native_type"],
|
||||
power: drive["power"],
|
||||
shift_state: drive["shift_state"],
|
||||
speed: drive["speed"],
|
||||
timestamp: drive["timestamp"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule VehicleConfig do
|
||||
defstruct [
|
||||
:can_accept_navigation_requests,
|
||||
:can_actuate_trunks,
|
||||
:car_special_type,
|
||||
:car_type,
|
||||
:charge_port_type,
|
||||
:eu_vehicle,
|
||||
:exterior_color,
|
||||
:has_air_suspension,
|
||||
:has_ludicrous_mode,
|
||||
:key_version,
|
||||
:motorized_charge_port,
|
||||
:perf_config,
|
||||
:plg,
|
||||
:rear_seat_heaters,
|
||||
:rear_seat_type,
|
||||
:rhd,
|
||||
:roof_color,
|
||||
:seat_type,
|
||||
:spoiler_type,
|
||||
:sun_roof_installed,
|
||||
:third_row_seats,
|
||||
:timestamp,
|
||||
:trim_badging,
|
||||
:use_range_badging,
|
||||
:wheel_type
|
||||
]
|
||||
|
||||
def result(vehicle_config) when is_map(vehicle_config) do
|
||||
%__MODULE__{
|
||||
can_accept_navigation_requests: vehicle_config["can_accept_navigation_requests"],
|
||||
can_actuate_trunks: vehicle_config["can_actuate_trunks"],
|
||||
car_special_type: vehicle_config["car_special_type"],
|
||||
car_type: vehicle_config["car_type"],
|
||||
charge_port_type: vehicle_config["charge_port_type"],
|
||||
eu_vehicle: vehicle_config["eu_vehicle"],
|
||||
exterior_color: vehicle_config["exterior_color"],
|
||||
has_air_suspension: vehicle_config["has_air_suspension"],
|
||||
has_ludicrous_mode: vehicle_config["has_ludicrous_mode"],
|
||||
key_version: vehicle_config["key_version"],
|
||||
motorized_charge_port: vehicle_config["motorized_charge_port"],
|
||||
perf_config: vehicle_config["perf_config"],
|
||||
plg: vehicle_config["plg"],
|
||||
rear_seat_heaters: vehicle_config["rear_seat_heaters"],
|
||||
rear_seat_type: vehicle_config["rear_seat_type"],
|
||||
rhd: vehicle_config["rhd"],
|
||||
roof_color: vehicle_config["roof_color"],
|
||||
seat_type: vehicle_config["seat_type"],
|
||||
spoiler_type: vehicle_config["spoiler_type"],
|
||||
sun_roof_installed: vehicle_config["sun_roof_installed"],
|
||||
third_row_seats: vehicle_config["third_row_seats"],
|
||||
timestamp: vehicle_config["timestamp"],
|
||||
trim_badging: vehicle_config["trim_badging"],
|
||||
use_range_badging: vehicle_config["use_range_badging"],
|
||||
wheel_type: vehicle_config["wheel_type"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule VehicleState do
|
||||
defstruct [
|
||||
:api_version,
|
||||
:autopark_state_v3,
|
||||
:autopark_style,
|
||||
:calendar_supported,
|
||||
:car_version,
|
||||
:center_display_state,
|
||||
:df,
|
||||
:dr,
|
||||
:ft,
|
||||
:homelink_device_count,
|
||||
:homelink_nearby,
|
||||
:is_user_present,
|
||||
:last_autopark_error,
|
||||
:locked,
|
||||
:notifications_supported,
|
||||
:odometer,
|
||||
:parsed_calendar_supported,
|
||||
:pf,
|
||||
:pr,
|
||||
:remote_start,
|
||||
:remote_start_enabled,
|
||||
:remote_start_supported,
|
||||
:rt,
|
||||
:fd_window,
|
||||
:fp_window,
|
||||
:rd_window,
|
||||
:rp_window,
|
||||
:sentry_mode,
|
||||
:sentry_mode_available,
|
||||
:smart_summon_available,
|
||||
:software_update,
|
||||
:summon_standby_mode_enabled,
|
||||
:sun_roof_percent_open,
|
||||
:sun_roof_state,
|
||||
:timestamp,
|
||||
:valet_mode,
|
||||
:valet_pin_needed,
|
||||
:vehicle_name
|
||||
]
|
||||
|
||||
defmodule SoftwareUpdate do
|
||||
defstruct [
|
||||
:download_perc,
|
||||
:expected_duration_sec,
|
||||
:install_perc,
|
||||
:scheduled_time_ms,
|
||||
:status,
|
||||
:version
|
||||
]
|
||||
end
|
||||
|
||||
def result(vehicle_state) when is_map(vehicle_state) do
|
||||
%__MODULE__{
|
||||
api_version: vehicle_state["api_version"],
|
||||
autopark_state_v3: vehicle_state["autopark_state_v3"],
|
||||
autopark_style: vehicle_state["autopark_style"],
|
||||
calendar_supported: vehicle_state["calendar_supported"],
|
||||
car_version: vehicle_state["car_version"],
|
||||
center_display_state: vehicle_state["center_display_state"],
|
||||
df: vehicle_state["df"],
|
||||
dr: vehicle_state["dr"],
|
||||
ft: vehicle_state["ft"],
|
||||
homelink_device_count: vehicle_state["homelink_device_count"],
|
||||
homelink_nearby: vehicle_state["homelink_nearby"],
|
||||
is_user_present: vehicle_state["is_user_present"],
|
||||
last_autopark_error: vehicle_state["last_autopark_error"],
|
||||
locked: vehicle_state["locked"],
|
||||
notifications_supported: vehicle_state["notifications_supported"],
|
||||
odometer: vehicle_state["odometer"],
|
||||
parsed_calendar_supported: vehicle_state["parsed_calendar_supported"],
|
||||
pf: vehicle_state["pf"],
|
||||
pr: vehicle_state["pr"],
|
||||
remote_start: vehicle_state["remote_start"],
|
||||
remote_start_enabled: vehicle_state["remote_start_enabled"],
|
||||
remote_start_supported: vehicle_state["remote_start_supported"],
|
||||
rt: vehicle_state["rt"],
|
||||
software_update: %SoftwareUpdate{
|
||||
download_perc: vehicle_state["software_update"]["download_perc"],
|
||||
expected_duration_sec: vehicle_state["software_update"]["expected_duration_sec"],
|
||||
install_perc: vehicle_state["software_update"]["install_perc"],
|
||||
scheduled_time_ms: vehicle_state["software_update"]["scheduled_time_ms"],
|
||||
status: vehicle_state["software_update"]["status"],
|
||||
version: vehicle_state["software_update"]["version"]
|
||||
},
|
||||
summon_standby_mode_enabled: vehicle_state["summon_standby_mode_enabled"],
|
||||
sun_roof_percent_open: vehicle_state["sun_roof_percent_open"],
|
||||
sun_roof_state: vehicle_state["sun_roof_state"],
|
||||
timestamp: vehicle_state["timestamp"],
|
||||
valet_mode: vehicle_state["valet_mode"],
|
||||
fd_window: vehicle_state["fd_window"],
|
||||
fp_window: vehicle_state["fp_window"],
|
||||
rd_window: vehicle_state["rd_window"],
|
||||
rp_window: vehicle_state["rp_window"],
|
||||
sentry_mode: vehicle_state["sentry_mode"],
|
||||
sentry_mode_available: vehicle_state["sentry_mode_available"],
|
||||
smart_summon_available: vehicle_state["smart_summon_available"],
|
||||
valet_pin_needed: vehicle_state["valet_pin_needed"],
|
||||
vehicle_name: vehicle_state["vehicle_name"]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,12 +7,15 @@ defmodule TeslaMate.Api do
|
||||
alias TeslaMate.Auth
|
||||
alias TeslaMate.Vehicles
|
||||
|
||||
alias Mojito.Response
|
||||
|
||||
import Core.Dependency, only: [call: 3, call: 2]
|
||||
|
||||
defstruct auth: nil, deps: %{}, refs: %{}
|
||||
alias __MODULE__, as: State
|
||||
|
||||
@name __MODULE__
|
||||
@timeout 65_000
|
||||
|
||||
# API
|
||||
|
||||
@@ -23,15 +26,15 @@ defmodule TeslaMate.Api do
|
||||
## State
|
||||
|
||||
def list_vehicles(name \\ @name) do
|
||||
GenServer.call(name, :list, 35_000)
|
||||
GenServer.call(name, :list, @timeout)
|
||||
end
|
||||
|
||||
def get_vehicle(name \\ @name, id) do
|
||||
GenServer.call(name, {:get, id}, 35_000)
|
||||
GenServer.call(name, {:get, id}, @timeout)
|
||||
end
|
||||
|
||||
def get_vehicle_with_state(name \\ @name, id) do
|
||||
GenServer.call(name, {:get_with_state, id}, 35_000)
|
||||
GenServer.call(name, {:get_with_state, id}, @timeout)
|
||||
end
|
||||
|
||||
## Internals
|
||||
@@ -71,7 +74,7 @@ defmodule TeslaMate.Api do
|
||||
:ok = call(state.deps.vehicles, :restart)
|
||||
{:reply, :ok, %State{state | auth: auth}, {:continue, :schedule_refresh}}
|
||||
|
||||
{:error, %TeslaApi.Error{error: reason}} ->
|
||||
{:error, %TeslaApi.Error{reason: reason}} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
@@ -103,55 +106,28 @@ defmodule TeslaMate.Api do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({ref, {:list, result}}, %State{refs: refs} = state) do
|
||||
def handle_info({ref, {cmd, result}}, %State{refs: refs} = state)
|
||||
when cmd in [:list, :get, :get_with_state] do
|
||||
{reply, state} =
|
||||
case result do
|
||||
{:error, %TeslaApi.Error{env: %Tesla.Env{status: 401}}} ->
|
||||
{:error, %TeslaApi.Error{reason: :unauthorized}} ->
|
||||
{{:error, :not_signed_in}, %State{state | auth: nil}}
|
||||
|
||||
{:error, %TeslaApi.Error{error: reason, env: %Tesla.Env{status: status, body: body}}} ->
|
||||
{:error, %TeslaApi.Error{reason: reason, env: %Response{status_code: status, body: body}}} ->
|
||||
Logger.error("TeslaApi.Error / #{status} – #{inspect(body, pretty: true)}")
|
||||
{{:error, reason}, state}
|
||||
|
||||
{:error, %TeslaApi.Error{error: reason, message: _msg}} ->
|
||||
{:error, %TeslaApi.Error{reason: reason, message: msg}} ->
|
||||
if is_binary(msg) and msg != "", do: Logger.warn("TeslaApi.Error / #{msg}")
|
||||
{{:error, reason}, state}
|
||||
|
||||
{:ok, vehicles} ->
|
||||
{:ok, vehicles} when is_list(vehicles) ->
|
||||
vehicles =
|
||||
vehicles
|
||||
|> Task.async_stream(&preload_vehicle(&1, state), timeout: 32_500)
|
||||
|> Enum.map(fn {:ok, vehicle} -> vehicle end)
|
||||
|
||||
{{:ok, vehicles}, state}
|
||||
end
|
||||
|
||||
{from, refs} = Map.pop(refs, ref)
|
||||
GenServer.reply(from, reply)
|
||||
|
||||
{:noreply, %State{state | refs: refs}}
|
||||
end
|
||||
|
||||
def handle_info({ref, {_cmd, result}}, %State{refs: refs} = state) do
|
||||
{reply, state} =
|
||||
case result do
|
||||
{:error, %TeslaApi.Error{env: %Tesla.Env{status: 401}}} ->
|
||||
{{:error, :not_signed_in}, %State{state | auth: nil}}
|
||||
|
||||
{:error, %TeslaApi.Error{env: %Tesla.Env{status: 404, body: %{"error" => "not_found"}}}} ->
|
||||
{{:error, :vehicle_not_found}, state}
|
||||
|
||||
{:error,
|
||||
%TeslaApi.Error{
|
||||
env: %Tesla.Env{status: 405, body: %{"error" => "vehicle is curently in service"}}
|
||||
}} ->
|
||||
{{:error, :in_service}, state}
|
||||
|
||||
{:error, %TeslaApi.Error{error: reason, env: %Tesla.Env{status: status, body: body}}} ->
|
||||
Logger.error("TeslaApi.Error / #{status} – #{inspect(body, pretty: true)}")
|
||||
{{:error, reason}, state}
|
||||
|
||||
{:error, %TeslaApi.Error{error: reason, message: _msg}} ->
|
||||
{{:error, reason}, state}
|
||||
|
||||
{:ok, %TeslaApi.Vehicle{} = vehicle} ->
|
||||
{{:ok, vehicle}, state}
|
||||
@@ -171,8 +147,8 @@ defmodule TeslaMate.Api do
|
||||
:ok = call(state.deps.auth, :save, [auth])
|
||||
{:noreply, %State{state | auth: auth}, {:continue, :schedule_refresh}}
|
||||
|
||||
{:error, %TeslaApi.Error{error: error, message: reason, env: _}} ->
|
||||
{:stop, {error, reason}}
|
||||
{:error, %TeslaApi.Error{reason: reason, message: message}} ->
|
||||
{:stop, {reason, message}}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -184,11 +160,12 @@ defmodule TeslaMate.Api do
|
||||
with %Tokens{access: access, refresh: refresh} <- call(state.deps.auth, :get_tokens),
|
||||
api_auth = %TeslaApi.Auth{token: access, refresh_token: refresh},
|
||||
{:ok, %TeslaApi.Auth{} = auth} <- call(state.deps.tesla_api_auth, :refresh, [api_auth]) do
|
||||
Logger.info("Refreshed api tokens")
|
||||
:ok = call(state.deps.auth, :save, [auth])
|
||||
{:noreply, %State{state | auth: auth}, {:continue, :schedule_refresh}}
|
||||
else
|
||||
nil ->
|
||||
Logger.info("Please sign in.")
|
||||
Logger.info("Please sign in")
|
||||
{:noreply, state}
|
||||
|
||||
{:error, %TeslaApi.Error{} = error} ->
|
||||
@@ -204,6 +181,7 @@ defmodule TeslaMate.Api do
|
||||
|> round()
|
||||
|> :timer.seconds()
|
||||
|
||||
Logger.info("Scheduling token refresh in #{round(ms / (24 * 60 * 60 * 1000))}d")
|
||||
Process.send_after(self(), :refresh_auth, ms)
|
||||
|
||||
{:noreply, state}
|
||||
|
||||
@@ -94,7 +94,7 @@ defmodule TeslaMate.Vehicles do
|
||||
end
|
||||
|
||||
defp create_or_update!(%TeslaApi.Vehicle{} = vehicle) do
|
||||
Logger.info("Found '#{vehicle.display_name}'")
|
||||
unless is_nil(name = vehicle.display_name), do: Logger.info("Starting logger for '#{name}'")
|
||||
|
||||
{:ok, car} =
|
||||
with nil <- Log.get_car_by(vin: vehicle.vin),
|
||||
|
||||
@@ -265,7 +265,7 @@ defmodule TeslaMate.Vehicles.Vehicle do
|
||||
Logger.warn("Error / connection closed", car_id: data.car.id)
|
||||
{:keep_state_and_data, schedule_fetch(5)}
|
||||
|
||||
{:error, :in_service} ->
|
||||
{:error, :vehicle_in_service} ->
|
||||
Logger.info("Vehicle is currently in service", car_id: data.car.id)
|
||||
{:keep_state_and_data, schedule_fetch(60)}
|
||||
|
||||
|
||||
@@ -99,12 +99,13 @@ defmodule TeslaMateWeb.CarLive.Summary do
|
||||
defp translate_error(:preconditioning), do: gettext("Preconditioning")
|
||||
defp translate_error(:user_present), do: gettext("Driver present")
|
||||
defp translate_error(:update_in_progress), do: gettext("Update in progress")
|
||||
defp translate_error(:unknown), do: gettext("An error occurred")
|
||||
defp translate_error(:timeout), do: gettext("Timeout")
|
||||
|
||||
defp translate_error(:sleep_mode_disabled_at_location),
|
||||
do: gettext("Sleep Mode is disabled at current location")
|
||||
|
||||
defp translate_error(_other), do: gettext("An error occurred")
|
||||
|
||||
defp cancel_timer(nil), do: :ok
|
||||
defp cancel_timer(ref) when is_reference(ref), do: Process.cancel_timer(ref)
|
||||
|
||||
|
||||
2
mix.exs
2
mix.exs
@@ -46,8 +46,6 @@ defmodule TeslaMate.MixProject do
|
||||
{:gettext, "~> 0.11"},
|
||||
{:jason, "~> 1.0"},
|
||||
{:plug_cowboy, "~> 2.0"},
|
||||
# Custom
|
||||
{:tesla_api, github: "adriankumpf/tesla_api", branch: "v10"},
|
||||
{:gen_state_machine, "~> 2.0"},
|
||||
{:ecto_enum, "~> 1.0"},
|
||||
{:phoenix_live_view, "~> 0.1"},
|
||||
|
||||
1
mix.lock
1
mix.lock
@@ -47,7 +47,6 @@
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
|
||||
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm"},
|
||||
"tesla": {:hex, :tesla, "1.3.0", "f35d72f029e608f9cdc6f6d6fcc7c66cf6d6512a70cfef9206b21b8bd0203a30", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 0.4", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"tesla_api": {:git, "https://github.com/adriankumpf/tesla_api.git", "da8941cd4b93530d1a059116cd047a30a9132b81", [branch: "v10"]},
|
||||
"tortoise": {:hex, :tortoise, "0.9.4", "3bca5f4475f80a5bd45aab644ddb3364dd0956b67f4202dcef6ed20e045ea3a6", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
|
||||
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm"},
|
||||
|
||||
@@ -405,16 +405,16 @@ msgid "Sleep Mode Enabled"
|
||||
msgstr "Schlafmodus aktiviert"
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:106
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:105
|
||||
msgid "Sleep Mode is disabled at current location"
|
||||
msgstr "Schlafmodus ist am aktuellen Ort deaktiviert"
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:102
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:107
|
||||
msgid "An error occurred"
|
||||
msgstr "Ein Fehler ist aufgetreten"
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:103
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:102
|
||||
msgid "Timeout"
|
||||
msgstr ""
|
||||
|
||||
@@ -404,16 +404,16 @@ msgid "Sleep Mode Enabled"
|
||||
msgstr ""
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:106
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:105
|
||||
msgid "Sleep Mode is disabled at current location"
|
||||
msgstr ""
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:102
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:107
|
||||
msgid "An error occurred"
|
||||
msgstr ""
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:103
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:102
|
||||
msgid "Timeout"
|
||||
msgstr ""
|
||||
|
||||
@@ -405,16 +405,16 @@ msgid "Sleep Mode Enabled"
|
||||
msgstr ""
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:106
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:105
|
||||
msgid "Sleep Mode is disabled at current location"
|
||||
msgstr ""
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:102
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:107
|
||||
msgid "An error occurred"
|
||||
msgstr ""
|
||||
|
||||
#, elixir-format
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:103
|
||||
#: lib/teslamate_web/live/car_live/summary.ex:102
|
||||
msgid "Timeout"
|
||||
msgstr ""
|
||||
|
||||
@@ -27,7 +27,7 @@ defmodule TeslaApi.AuthMock do
|
||||
%State{pid: pid} = state
|
||||
) do
|
||||
send(pid, {TeslaApi.AuthMock, event})
|
||||
{:reply, {:error, %TeslaApi.Error{error: :induced_error}}, state}
|
||||
{:reply, {:error, %TeslaApi.Error{reason: :induced_error, message: "foo"}}, state}
|
||||
end
|
||||
|
||||
def handle_call(event, _from, %State{pid: pid} = state) do
|
||||
|
||||
@@ -9,7 +9,9 @@ defmodule TeslaMate.ApiErrorsTest do
|
||||
@valid_credentials %Credentials{email: "teslamate", password: "foo"}
|
||||
|
||||
test "sign_in", %{test: name} do
|
||||
login = fn _email, _password -> {:error, %TeslaApi.Error{error: :unauthorized}} end
|
||||
login = fn _email, _password ->
|
||||
{:error, %TeslaApi.Error{reason: :unauthorized, env: %Mojito.Response{}}}
|
||||
end
|
||||
|
||||
with_mock TeslaApi.Auth, login: login do
|
||||
:ok = start_real_api(name)
|
||||
@@ -21,9 +23,15 @@ defmodule TeslaMate.ApiErrorsTest do
|
||||
vehicle_mock =
|
||||
{TeslaApi.Vehicle, [],
|
||||
[
|
||||
list: fn _ -> {:error, %TeslaApi.Error{env: %Tesla.Env{status: 401}}} end,
|
||||
get: fn _, _ -> {:error, %TeslaApi.Error{env: %Tesla.Env{status: 401}}} end,
|
||||
get_with_state: fn _, _ -> {:error, %TeslaApi.Error{env: %Tesla.Env{status: 401}}} end
|
||||
list: fn _ ->
|
||||
{:error, %TeslaApi.Error{reason: :unauthorized, env: %Mojito.Response{}}}
|
||||
end,
|
||||
get: fn _, _ ->
|
||||
{:error, %TeslaApi.Error{reason: :unauthorized, env: %Mojito.Response{}}}
|
||||
end,
|
||||
get_with_state: fn _, _ ->
|
||||
{:error, %TeslaApi.Error{reason: :unauthorized, env: %Mojito.Response{}}}
|
||||
end
|
||||
]}
|
||||
|
||||
with_mocks [auth_mock(), vehicle_mock] do
|
||||
@@ -40,8 +48,9 @@ defmodule TeslaMate.ApiErrorsTest do
|
||||
end
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test ":vehicle_not_found", %{test: name} do
|
||||
api_error = %TeslaApi.Error{env: %Tesla.Env{status: 404, body: %{"error" => "not_found"}}}
|
||||
api_error = %TeslaApi.Error{reason: :vehicle_not_found, env: %Mojito.Response{}}
|
||||
|
||||
vehicle_mock =
|
||||
{TeslaApi.Vehicle, [],
|
||||
@@ -61,7 +70,11 @@ defmodule TeslaMate.ApiErrorsTest do
|
||||
|
||||
@tag :capture_log
|
||||
test "other error witn Env", %{test: name} do
|
||||
api_error = %TeslaApi.Error{error: :unkown, env: %Tesla.Env{status: 503, body: ""}}
|
||||
api_error = %TeslaApi.Error{
|
||||
reason: :unknown,
|
||||
message: "",
|
||||
env: %Mojito.Response{status_code: 503, body: ""}
|
||||
}
|
||||
|
||||
vehicle_mock =
|
||||
{TeslaApi.Vehicle, [],
|
||||
@@ -75,14 +88,15 @@ defmodule TeslaMate.ApiErrorsTest do
|
||||
:ok = start_real_api(name)
|
||||
|
||||
assert :ok = Api.sign_in(@valid_credentials)
|
||||
assert {:error, :unkown} = Api.list_vehicles()
|
||||
assert {:error, :unkown} = Api.get_vehicle(0)
|
||||
assert {:error, :unkown} = Api.get_vehicle_with_state(0)
|
||||
assert {:error, :unknown} = Api.list_vehicles()
|
||||
assert {:error, :unknown} = Api.get_vehicle(0)
|
||||
assert {:error, :unknown} = Api.get_vehicle_with_state(0)
|
||||
end
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "other error witnout Env", %{test: name} do
|
||||
api_error = %TeslaApi.Error{error: :closed, message: "foo"}
|
||||
api_error = %TeslaApi.Error{reason: :closed, message: "foo"}
|
||||
|
||||
vehicle_mock =
|
||||
{TeslaApi.Vehicle, [],
|
||||
|
||||
@@ -3,10 +3,10 @@ defmodule TeslaMate.Vehicles.VehicleTest do
|
||||
|
||||
describe "starting" do
|
||||
@tag :capture_log
|
||||
test "handles unkown and faulty states", %{test: name} do
|
||||
test "handles unknown and faulty states", %{test: name} do
|
||||
events = [
|
||||
{:ok, %TeslaApi.Vehicle{state: "unknown"}},
|
||||
{:error, %TeslaApi.Error{message: "boom"}}
|
||||
{:error, %TeslaApi.Error{reason: :boom, message: "boom"}}
|
||||
]
|
||||
|
||||
:ok = start_vehicle(name, events)
|
||||
@@ -237,11 +237,11 @@ defmodule TeslaMate.Vehicles.VehicleTest do
|
||||
events = [
|
||||
{:ok, online_event()},
|
||||
{:ok, online_event()},
|
||||
{:error, :in_service},
|
||||
{:error, :in_service},
|
||||
{:error, :in_service},
|
||||
{:error, :in_service},
|
||||
{:error, :in_service},
|
||||
{:error, :vehicle_in_service},
|
||||
{:error, :vehicle_in_service},
|
||||
{:error, :vehicle_in_service},
|
||||
{:error, :vehicle_in_service},
|
||||
{:error, :vehicle_in_service},
|
||||
{:ok, online_event()},
|
||||
{:ok, online_event()}
|
||||
]
|
||||
|
||||
@@ -321,7 +321,7 @@ defmodule TeslaMateWeb.CarControllerTest do
|
||||
@tag :signed_in
|
||||
test "renders current vehicle stats [:unavailable]", %{conn: conn} do
|
||||
events = [
|
||||
{:error, :unkown}
|
||||
{:error, :unknown}
|
||||
]
|
||||
|
||||
:ok = start_vehicles(events)
|
||||
|
||||
Reference in New Issue
Block a user