wip(tesla_api_coverage): scrape vehicle command

This commit is contained in:
gak 2023-10-23 14:25:33 +11:00
parent 503effc7ef
commit d0b8f6df67
No known key found for this signature in database
12 changed files with 3995 additions and 2190 deletions

16
API.md Normal file
View file

@ -0,0 +1,16 @@
# Tesla API Matrix
List of all known Tesla APIs, and if this crate supports it, and which of the Tesla enpoints support them.
### Legend
- Blank - Unknown
- ✅ Supported by this crate
- 🟢 Supported by specified API
- 🔴 Not supported by specified API
Currently only the Owner API is partially supported by this crate.
| API | Owner API | Fleet API | Command Mode |
|-----------| --- | --- | --- |
| honk_horn | ✅ | | |

File diff suppressed because it is too large Load diff

View file

@ -1,425 +0,0 @@
use clap::{Command, Parser};
use scraper::{Element, ElementRef, Html, Selector};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Parser, Debug)]
#[clap(author, version)]
struct Cli {
/// Only use the cached html to avoid making a request.
#[clap(short, long)]
cache: bool,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let args = Cli::parse();
let html = get_and_save_html(&args).await;
let fleet_api = parse(&html);
}
async fn get_and_save_html(args: &Cli) -> String {
// Write to where this project root is, not in the parent project.
let mut path = PathBuf::new();
path.push(env!("CARGO_MANIFEST_DIR"));
path.push("fleet.html");
if args.cache {
return std::fs::read_to_string(path).unwrap();
}
let response = reqwest::get("https://developer.tesla.com/docs/fleet-api")
.await
.unwrap();
let html = response.text().await.unwrap();
std::fs::write(path, &html).unwrap();
html
}
struct FleetApiSpec {
calls: HashMap<String, Call>,
}
// e.g. serialize to similar: vehicle-endpoints
#[derive(Debug, strum::EnumString)]
#[strum(serialize_all = "kebab-case")]
enum Category {
ChargingEndpoints,
PartnerEndpoints,
UserEndpoints,
VehicleCommands,
VehicleEndpoints,
}
/*
Profile Information user_data Contact information, home address, profile picture, and referral information
Vehicle Information vehicle_device_data Vehicle live data, location, eligible upgrades, nearby superchargers, ownership, and service scheduling data
Vehicle Commands vehicle_cmds Commands like add/remove driver, access Live Camera, unlock, wake up, remote start, and schedule software updates
Vehicle Charging Management vehicle_charging_cmds Vehicle charging history, billed amount, charging location, commands to schedule, and start/stop charging
Energy Product Information energy_device_data Energy flow history, saving forecast, tariff rates, grid import, calendar, site status, time of use, and ownership
Energy Product Commands energy_cmds Commands like update storm mode
*/
#[derive(Debug, strum::EnumString)]
#[strum(serialize_all = "snake_case")]
enum Scope {
/// Profile Information
///
/// Contact information, home address, profile picture, and referral information.
UserData,
/// Vehicle Information
///
/// Vehicle live data, location, eligible upgrades, nearby superchargers, ownership, and service scheduling data.
VehicleDeviceData,
/// Vehicle Commands
///
/// Commands like add/remove driver, access Live Camera, unlock, wake up, remote start, and schedule software updates.
VehicleCmds,
/// Vehicle Charging Management
///
/// Vehicle charging history, billed amount, charging location, commands to schedule, and start/stop charging.
VehicleChargingCmds,
/// Energy Product Information
///
/// Energy flow history, saving forecast, tariff rates, grid import, calendar, site status, time of use, and ownership.
EnergyDeviceData,
/// Energy Product Commands
///
/// Commands like update storm mode.
EnergyCmds,
}
/*
Name In Type Required Description
vin query string No VIN
startTime query string No StartTime
endTime query string No EndTime
*/
enum InRequestData {
Query,
Body,
}
struct Parameter {
name: String,
request: InRequestData,
var_type: String,
required: bool,
description: String,
}
struct Call {
name: String,
method: reqwest::Method,
url_definition: String,
description: String,
category: Category,
scopes: Vec<Scope>,
parameters: Vec<Parameter>,
request_example: String,
response_example: String,
}
/*
Example HTML docs for two calls:
<h1 id='vehicle-commands'>Vehicle Commands</h1><h2 id='actuate_trunk'>actuate_trunk</h2>
<p><span class="endpoint"><code>POST /api/1/vehicles/{id}/command/actuate_trunk</code></span></p>
<p>scopes: <em>vehicle_cmds</em></p>
<div class="highlight"><pre class="highlight shell tab-shell"><code>curl <span class="nt">--header</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
<span class="nt">--header</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TESLA_API_TOKEN</span><span class="s2">"</span> <span class="se">\</span>
<span class="nt">--data</span> <span class="s1">'{"which_trunk":"string"}'</span> <span class="se">\</span>
<span class="s1">'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/{id}/command/actuate_trunk'</span>
</code></pre></div><div class="highlight"><pre class="highlight javascript tab-javascript"><code><span class="kd">const</span> <span class="nx">myHeaders</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Headers</span><span class="p">();</span>
<span class="nx">myHeaders</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">myHeaders</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">Authorization</span><span class="dl">"</span><span class="p">,</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">TESLA_API_TOKEN</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">body</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
<span class="dl">"</span><span class="s2">which_trunk</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">string</span><span class="dl">"</span>
<span class="p">});</span>
<span class="kd">const</span> <span class="nx">requestOptions</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
<span class="na">headers</span><span class="p">:</span> <span class="nx">myHeaders</span><span class="p">,</span>
<span class="nx">body</span><span class="p">,</span>
<span class="na">redirect</span><span class="p">:</span> <span class="dl">'</span><span class="s1">follow</span><span class="dl">'</span>
<span class="p">};</span>
<span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/{id}/command/actuate_trunk</span><span class="dl">"</span><span class="p">,</span> <span class="nx">requestOptions</span><span class="p">)</span>
<span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=&gt;</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">())</span>
<span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">result</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">result</span><span class="p">))</span>
<span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">error</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">error</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">));</span>
</code></pre></div><div class="highlight"><pre class="highlight ruby tab-ruby"><code><span class="nb">require</span> <span class="s2">"uri"</span>
<span class="nb">require</span> <span class="s2">"json"</span>
<span class="nb">require</span> <span class="s2">"net/http"</span>
<span class="n">url</span> <span class="o">=</span> <span class="no">URI</span><span class="p">(</span><span class="s2">"https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/{id}/command/actuate_trunk"</span><span class="p">)</span>
<span class="n">https</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">url</span><span class="p">.</span><span class="nf">host</span><span class="p">,</span> <span class="n">url</span><span class="p">.</span><span class="nf">port</span><span class="p">)</span>
<span class="n">https</span><span class="p">.</span><span class="nf">use_ssl</span> <span class="o">=</span> <span class="kp">true</span>
<span class="n">request</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">::</span><span class="no">Post</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
<span class="n">request</span><span class="p">[</span><span class="s2">"Content-Type"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"application/json"</span>
<span class="n">request</span><span class="p">[</span><span class="s2">"Authorization"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Bearer ENV_TESLA_API_TOKEN"</span>
<span class="n">request</span><span class="p">.</span><span class="nf">body</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">dump</span><span class="p">({</span>
<span class="s2">"which_trunk"</span><span class="p">:</span> <span class="s2">"string"</span>
<span class="p">})</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">https</span><span class="p">.</span><span class="nf">request</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>
<span class="nb">puts</span> <span class="n">response</span><span class="p">.</span><span class="nf">read_body</span>
</code></pre></div><div class="highlight"><pre class="highlight python tab-python"><code><span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">http.client</span>
<span class="kn">import</span> <span class="nn">json</span>
<span class="n">conn</span> <span class="o">=</span> <span class="n">http</span><span class="p">.</span><span class="n">client</span><span class="p">.</span><span class="n">HTTPSConnection</span><span class="p">(</span><span class="s">"fleet-api.prd.na.vn.cloud.tesla.com"</span><span class="p">)</span>
<span class="n">payload</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">({</span>
<span class="s">"which_trunk"</span><span class="p">:</span> <span class="s">"string"</span>
<span class="p">})</span>
<span class="n">headers</span> <span class="o">=</span> <span class="p">{</span>
<span class="s">'Content-Type'</span><span class="p">:</span> <span class="s">'application/json'</span><span class="p">,</span>
<span class="s">'Authorization'</span><span class="p">:</span> <span class="s">'Bearer ENV_TESLA_API_TOKEN'</span>
<span class="p">}</span>
<span class="n">conn</span><span class="p">.</span><span class="n">request</span><span class="p">(</span><span class="s">"POST"</span><span class="p">,</span> <span class="s">"/api/1/vehicles/{id}/command/actuate_trunk"</span><span class="p">,</span> <span class="n">payload</span><span class="p">,</span> <span class="n">headers</span><span class="p">)</span>
<span class="n">res</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="n">getresponse</span><span class="p">()</span>
<span class="n">data</span> <span class="o">=</span> <span class="n">res</span><span class="p">.</span><span class="n">read</span><span class="p">()</span>
<span class="k">print</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">decode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">))</span>
</code></pre></div>
<p>Controls the front (which_trunk: &quot;front&quot;) or rear (which_trunk: &quot;rear&quot;) trunk.</p>
<h3 id='parameters-10'>Parameters</h3>
<table><thead>
<tr>
<th>Name</th>
<th>In</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead><tbody>
<tr>
<td>id</td>
<td>path</td>
<td>integer</td>
<td>Yes</td>
<td>vehicle id</td>
</tr>
<tr>
<td>which_trunk</td>
<td>body</td>
<td>string</td>
<td>Yes</td>
<td></td>
</tr>
</tbody></table>
<div class="highlight"><pre class="highlight json tab-json"><code><details><summary> Click to view successful response</summary><span class="p">{</span><span class="w">
</span><span class="nl">"result"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
</span><span class="nl">"reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></details></code></pre></div>
<h2 id='adjust_volume'>adjust_volume</h2>
<p><span class="endpoint"><code>POST /api/1/vehicles/{id}/command/adjust_volume</code></span></p>
<p>scopes: <em>vehicle_cmds</em></p>
<div class="highlight"><pre class="highlight shell tab-shell"><code>curl <span class="nt">--header</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
<span class="nt">--header</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TESLA_API_TOKEN</span><span class="s2">"</span> <span class="se">\</span>
<span class="nt">--data</span> <span class="s1">'{"volume":"integer"}'</span> <span class="se">\</span>
<span class="s1">'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/{id}/command/adjust_volume'</span>
</code></pre></div><div class="highlight"><pre class="highlight javascript tab-javascript"><code><span class="kd">const</span> <span class="nx">myHeaders</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Headers</span><span class="p">();</span>
<span class="nx">myHeaders</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">myHeaders</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">Authorization</span><span class="dl">"</span><span class="p">,</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">TESLA_API_TOKEN</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">body</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
<span class="dl">"</span><span class="s2">volume</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">integer</span><span class="dl">"</span>
<span class="p">});</span>
<span class="kd">const</span> <span class="nx">requestOptions</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
<span class="na">headers</span><span class="p">:</span> <span class="nx">myHeaders</span><span class="p">,</span>
<span class="nx">body</span><span class="p">,</span>
<span class="na">redirect</span><span class="p">:</span> <span class="dl">'</span><span class="s1">follow</span><span class="dl">'</span>
<span class="p">};</span>
<span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/{id}/command/adjust_volume</span><span class="dl">"</span><span class="p">,</span> <span class="nx">requestOptions</span><span class="p">)</span>
<span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=&gt;</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">())</span>
<span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">result</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">result</span><span class="p">))</span>
<span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">error</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">error</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">));</span>
</code></pre></div><div class="highlight"><pre class="highlight ruby tab-ruby"><code><span class="nb">require</span> <span class="s2">"uri"</span>
<span class="nb">require</span> <span class="s2">"json"</span>
<span class="nb">require</span> <span class="s2">"net/http"</span>
<span class="n">url</span> <span class="o">=</span> <span class="no">URI</span><span class="p">(</span><span class="s2">"https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/{id}/command/adjust_volume"</span><span class="p">)</span>
<span class="n">https</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">url</span><span class="p">.</span><span class="nf">host</span><span class="p">,</span> <span class="n">url</span><span class="p">.</span><span class="nf">port</span><span class="p">)</span>
<span class="n">https</span><span class="p">.</span><span class="nf">use_ssl</span> <span class="o">=</span> <span class="kp">true</span>
<span class="n">request</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">::</span><span class="no">Post</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
<span class="n">request</span><span class="p">[</span><span class="s2">"Content-Type"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"application/json"</span>
<span class="n">request</span><span class="p">[</span><span class="s2">"Authorization"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Bearer ENV_TESLA_API_TOKEN"</span>
<span class="n">request</span><span class="p">.</span><span class="nf">body</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">dump</span><span class="p">({</span>
<span class="s2">"volume"</span><span class="p">:</span> <span class="s2">"integer"</span>
<span class="p">})</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">https</span><span class="p">.</span><span class="nf">request</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>
<span class="nb">puts</span> <span class="n">response</span><span class="p">.</span><span class="nf">read_body</span>
</code></pre></div><div class="highlight"><pre class="highlight python tab-python"><code><span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">http.client</span>
<span class="kn">import</span> <span class="nn">json</span>
<span class="n">conn</span> <span class="o">=</span> <span class="n">http</span><span class="p">.</span><span class="n">client</span><span class="p">.</span><span class="n">HTTPSConnection</span><span class="p">(</span><span class="s">"fleet-api.prd.na.vn.cloud.tesla.com"</span><span class="p">)</span>
<span class="n">payload</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">({</span>
<span class="s">"volume"</span><span class="p">:</span> <span class="s">"integer"</span>
<span class="p">})</span>
<span class="n">headers</span> <span class="o">=</span> <span class="p">{</span>
<span class="s">'Content-Type'</span><span class="p">:</span> <span class="s">'application/json'</span><span class="p">,</span>
<span class="s">'Authorization'</span><span class="p">:</span> <span class="s">'Bearer ENV_TESLA_API_TOKEN'</span>
<span class="p">}</span>
<span class="n">conn</span><span class="p">.</span><span class="n">request</span><span class="p">(</span><span class="s">"POST"</span><span class="p">,</span> <span class="s">"/api/1/vehicles/{id}/command/adjust_volume"</span><span class="p">,</span> <span class="n">payload</span><span class="p">,</span> <span class="n">headers</span><span class="p">)</span>
<span class="n">res</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="n">getresponse</span><span class="p">()</span>
<span class="n">data</span> <span class="o">=</span> <span class="n">res</span><span class="p">.</span><span class="n">read</span><span class="p">()</span>
<span class="k">print</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">decode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">))</span>
</code></pre></div>
<p>Adjusts vehicle media playback volume.</p>
<h3 id='parameters-11'>Parameters</h3>
<table><thead>
<tr>
<th>Name</th>
<th>In</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead><tbody>
<tr>
<td>id</td>
<td>path</td>
<td>integer</td>
<td>Yes</td>
<td>vehicle id</td>
</tr>
<tr>
<td>volume</td>
<td>body</td>
<td>integer</td>
<td>Yes</td>
<td></td>
</tr>
</tbody></table>
<div class="highlight"><pre class="highlight json tab-json"><code><details><summary> Click to view successful response</summary><span class="p">{</span><span class="w">
</span><span class="nl">"result"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
</span><span class="nl">"reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></details></code></pre></div>
*/
fn parse(html: &str) -> FleetApiSpec {
let document = Html::parse_document(html);
let content_selector = selector(".content h1");
let mut element = document.select(&content_selector).next().unwrap();
let mut category = None;
// Iterate over all the elements in the content section until we see a h1 or h2.
loop {
match element.value().name() {
"h1" => {
let category_name = element.value().id().unwrap();
category = Category::from_str(&category_name).ok();
}
"h2" => {
if category.is_some() {
let name = element.inner_html();
println!("{category:?} {name:?}");
// let call = parse_call(element);
}
}
_ => {}
}
let Some(next_element) = element.next_sibling_element() else {
println!("exiting...");
break;
};
element = next_element;
}
todo!()
}
/// Return None if this is not an endpoint.
///
/// Will panic if it looks like an endpoint and has trouble parsing.
fn parse_call(element: ElementRef) -> Option<Call> {
let name = element.value().id().unwrap();
// <p><span class="endpoint"><code>POST /api/1/vehicles/{id}/command/auto_conditioning_start</code></span></p>
// This section determines if this is an endpoint or not.
let (fragment, element) = next(element);
let url = fragment.select(&selector("code")).next()?.inner_html();
if !url.starts_with("GET ") && !url.starts_with("POST ") {
return None;
}
let (method, url) = url.split_once(' ').unwrap();
println!("{} {}", method, url);
// <p>scopes: <em>vehicle_cmds</em></p>
let (fragment, element) = next(element);
let scopes = fragment
.select(&selector("em"))
.map(|e| e.inner_html())
.map(|e| Scope::from_str(&e))
.collect::<Vec<_>>();
// 4 <div class="highlight"> nodes containing example requests in different languages.
// TODO: Skip for now
let mut count = 0;
let mut element = element;
loop {
let (fragment, new_element) = next(element);
element = new_element;
if fragment
.select(&selector(r#"div[class="highlight"]"#))
.next()
.is_none()
{
break;
}
count += 1;
if count == 10 {
panic!("Too many examples");
}
}
if count == 0 && name != "api-status" {
panic!("No examples for {}", name);
}
None
}
fn next(element: ElementRef) -> (Html, ElementRef) {
let element = element.next_sibling_element().unwrap();
let html = Html::parse_fragment(&element.html());
(html, element)
}
fn selector(s: &str) -> Selector {
Selector::parse(s).unwrap()
}

View file

@ -1,5 +1,5 @@
[package]
name = "scrape_fleet_api"
name = "tesla_api_coverage"
version = "0.1.0"
edition = "2021"
@ -7,6 +7,10 @@ edition = "2021"
reqwest = "0.11.22"
tokio = { version = "1.33.0", features = ["full"] }
clap = { version = "4.4.6", features = ["derive"] }
tracing-subscriber = "0.3.17"
scraper = "0.17.1"
strum = { version = "0.25.0", features = ["derive"] }
tracing-subscriber = "0.3.17"
tracing = "0.1.40"
log = "0.4.20"
nom = "7.1.3"
anyhow = "1.0.75"

View file

@ -0,0 +1,42 @@
# API Coverage
A tool designed to compare the API calls between `teslatte` and the publicly documented Tesla APIs.
**Note:** This tool is bespoke to the build of `teslatte` and is not intended for publishing on crates.io.
This project does (or will do) the following:
* Scrape the teslatte project for what has been implemented.
* Scrape the Fleet API for its list of endpoints.
* Scrape the Command Mode SDK sources for its list of endpoints: https://github.com/teslamotors/vehicle-command/blob/main/cmd/tesla-control/commands.go
* Scrape timdorr/tesla-api's endpoints file: https://github.com/timdorr/tesla-api/blob/master/ownerapi_endpoints.json
* Combine the results into a single list of endpoints.
* Has a configuration on how to merge the endpoints, e.g. if an endpoint name is different, how to resolve it.
* Output a table of endpoints that are implemented or not, maybe in Markdown.
### Brainstorm
Combined format idea:
```json
{
"honk_horn": {
// If owner-api vs fleet-api methods are different, they should have different entries,
// otherwise call it "rest":
"rest": {
"method": "POST",
"endpoint": "/vehicles/{vehicle_id}/command/honk_horn"
},
"vehicle-command": {
"endpoint": "honk"
}
"timdorr-endpoints-file": true,
"teslatte": true,
"owners-api": true,
"fleet-api": true,
}
}
```

View file

@ -2248,9 +2248,7 @@ The response body is typically JSON-encoded with details in the &quot;error&quot
<span class="nt">--header</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TESLA_API_TOKEN</span><span class="s2">"</span> <span class="se">\</span>
<span class="nt">--data</span> <span class="s1">'{}'</span> <span class="se">\</span>
<span class="s1">'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/{id}/command/auto_conditioning_start'</span>
</code></pre></div>
<div class="highlight"><pre class="highlight javascript tab-javascript"><code><span class="kd">const</span> <span class="nx">myHeaders</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Headers</span><span class="p">();</span>
</code></pre></div><div class="highlight"><pre class="highlight javascript tab-javascript"><code><span class="kd">const</span> <span class="nx">myHeaders</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Headers</span><span class="p">();</span>
<span class="nx">myHeaders</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">myHeaders</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">Authorization</span><span class="dl">"</span><span class="p">,</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">TESLA_API_TOKEN</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
@ -2267,9 +2265,7 @@ The response body is typically JSON-encoded with details in the &quot;error&quot
<span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=&gt;</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">())</span>
<span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">result</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">result</span><span class="p">))</span>
<span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">error</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">error</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">));</span>
</code></pre></div>
<div class="highlight"><pre class="highlight ruby tab-ruby"><code><span class="nb">require</span> <span class="s2">"uri"</span>
</code></pre></div><div class="highlight"><pre class="highlight ruby tab-ruby"><code><span class="nb">require</span> <span class="s2">"uri"</span>
<span class="nb">require</span> <span class="s2">"json"</span>
<span class="nb">require</span> <span class="s2">"net/http"</span>
@ -2301,7 +2297,6 @@ The response body is typically JSON-encoded with details in the &quot;error&quot
<span class="n">data</span> <span class="o">=</span> <span class="n">res</span><span class="p">.</span><span class="n">read</span><span class="p">()</span>
<span class="k">print</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">decode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">))</span>
</code></pre></div>
<p>Starts climate preconditioning.</p>
<h3 id='parameters-12'>Parameters</h3>
<table><thead>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,695 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/teslamotors/vehicle-command/pkg/account"
"github.com/teslamotors/vehicle-command/pkg/cli"
"github.com/teslamotors/vehicle-command/pkg/protocol"
"github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec"
"github.com/teslamotors/vehicle-command/pkg/vehicle"
)
var ErrCommandLineArgs = errors.New("invalid command line arguments")
type Argument struct {
name string
help string
}
type Handler func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error
type Command struct {
help string
requiresAuth bool // True if command requires client-to-vehicle authentication (private key)
requiresFleetAPI bool // True if command requires client-to-server authentication (OAuth token)
args []Argument
optional []Argument
handler Handler
}
// configureAndVerifyFlags verifies that c contains all the information required to execute a command.
func configureFlags(c *cli.Config, commandName string, forceBLE bool) error {
info, ok := commands[commandName]
if !ok {
return ErrUnknownCommand
}
c.Flags = cli.FlagBLE
if info.requiresAuth {
c.Flags |= cli.FlagPrivateKey | cli.FlagVIN
}
if !info.requiresFleetAPI {
c.Flags |= cli.FlagVIN
}
if forceBLE {
if info.requiresFleetAPI {
return ErrRequiresOAuth
}
} else {
c.Flags |= cli.FlagOAuth
}
// Verify all required parameters are present.
havePrivateKey := !(c.KeyringKeyName == "" && c.KeyFilename == "")
haveOAuth := !(c.KeyringTokenName == "" && c.TokenFilename == "")
haveVIN := c.VIN != ""
_, err := checkReadiness(commandName, havePrivateKey, haveOAuth, haveVIN)
return err
}
var (
ErrRequiresOAuth = errors.New("command requires a FleetAPI OAuth token")
ErrRequiresVIN = errors.New("command requires a VIN")
ErrRequiresPrivateKey = errors.New("command requires a private key")
ErrUnknownCommand = errors.New("unrecognized command")
)
func checkReadiness(commandName string, havePrivateKey, haveOAuth, haveVIN bool) (*Command, error) {
info, ok := commands[commandName]
if !ok {
return nil, ErrUnknownCommand
}
if info.requiresFleetAPI {
if !haveOAuth {
return nil, ErrRequiresOAuth
}
} else {
// Currently, commands supported by this application either target the account (and
// therefore require FleetAPI credentials but not a VIN) or target a vehicle (and therefore
// require a VIN but not FleetAPI credentials).
if !haveVIN {
return nil, ErrRequiresVIN
}
}
if info.requiresAuth && !havePrivateKey {
return nil, ErrRequiresPrivateKey
}
return info, nil
}
func execute(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args []string) error {
if len(args) == 0 {
return errors.New("missing COMMAND")
}
info, err := checkReadiness(args[0], car != nil && car.PrivateKeyAvailable(), acct != nil, car != nil)
if err != nil {
return err
}
if len(args)-1 < len(info.args) || len(args)-1 > len(info.args)+len(info.optional) {
writeErr("Invalid number of command line arguments: %d (%d required, %d optional).", len(args), len(info.args), len(info.optional))
err = ErrCommandLineArgs
} else {
keywords := make(map[string]string)
for i, argInfo := range info.args {
keywords[argInfo.name] = args[i+1]
}
index := len(info.args) + 1
for _, argInfo := range info.optional {
if index >= len(args) {
break
}
keywords[argInfo.name] = args[index]
index++
}
err = info.handler(ctx, acct, car, keywords)
}
// Print command-specific help
if errors.Is(err, ErrCommandLineArgs) {
info.Usage(args[0])
}
return err
}
func (c *Command) Usage(name string) {
fmt.Printf("Usage: %s", name)
maxLength := 0
for _, arg := range c.args {
fmt.Printf(" %s", arg.name)
if len(arg.name) > maxLength {
maxLength = len(arg.name)
}
}
if len(c.optional) > 0 {
fmt.Printf(" [")
}
for _, arg := range c.optional {
fmt.Printf(" %s", arg.name)
if len(arg.name) > maxLength {
maxLength = len(arg.name)
}
}
if len(c.optional) > 0 {
fmt.Printf(" ]")
}
fmt.Printf("\n%s\n", c.help)
maxLength++
for _, arg := range c.args {
fmt.Printf(" %s:%s%s\n", arg.name, strings.Repeat(" ", maxLength-len(arg.name)), arg.help)
}
for _, arg := range c.optional {
fmt.Printf(" %s:%s%s\n", arg.name, strings.Repeat(" ", maxLength-len(arg.name)), arg.help)
}
}
var commands = map[string]*Command{
"unlock": &Command{
help: "Unlock vehicle",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.Unlock(ctx)
},
},
"lock": &Command{
help: "Lock vehicle",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.Lock(ctx)
},
},
"drive": &Command{
help: "Remote start vehicle",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.RemoteDrive(ctx)
},
},
"climate-on": &Command{
help: "Turn on climate control",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.ClimateOn(ctx)
},
},
"climate-off": &Command{
help: "Turn off climate control",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.ClimateOff(ctx)
},
},
"climate-set-temp": &Command{
help: "Set temperature (Celsius)",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "TEMP", help: "Desired temperature (e.g., 70f or 21c; defaults to Celsius)"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var degrees float32
var unit string
if _, err := fmt.Sscanf(args["TEMP"], "%f%s", &degrees, &unit); err != nil {
return fmt.Errorf("failed to parse temperature: format as 22C or 72F")
}
if unit == "F" || unit == "f" {
degrees = (5.0 * degrees / 9.0) + 32.0
} else if unit != "C" && unit != "c" {
return fmt.Errorf("temperature units must be C or F")
}
return car.ChangeClimateTemp(ctx, degrees, degrees)
},
},
"add-key": &Command{
help: "Add PUBLIC_KEY to vehicle whitelist with ROLE and FORM_FACTOR",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"},
Argument{name: "ROLE", help: "One of: owner, driver"},
Argument{name: "FORM_FACTOR", help: "One of: nfc_card, ios_device, android_device, cloud_key"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
role := strings.ToUpper(args["ROLE"])
if role != "OWNER" && role != "DRIVER" {
return fmt.Errorf("%w: invalid ROLE", ErrCommandLineArgs)
}
formFactor, ok := vcsec.KeyFormFactor_value["KEY_FORM_FACTOR_"+strings.ToUpper(args["FORM_FACTOR"])]
if !ok {
return fmt.Errorf("%w: unrecognized FORM_FACTOR", ErrCommandLineArgs)
}
publicKey, err := protocol.LoadPublicKey(args["PUBLIC_KEY"])
if err != nil {
return fmt.Errorf("invalid public key: %s", err)
}
return car.AddKey(ctx, publicKey, role == "OWNER", vcsec.KeyFormFactor(formFactor))
},
},
"add-key-request": &Command{
help: "Requset NFC-card approval for a enrolling PUBLIC_KEY with ROLE and FORM_FACTOR",
requiresAuth: false,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"},
Argument{name: "ROLE", help: "One of: owner, driver"},
Argument{name: "FORM_FACTOR", help: "One of: nfc_card, ios_device, android_device, cloud_key"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
role := strings.ToUpper(args["ROLE"])
if role != "OWNER" && role != "DRIVER" {
return fmt.Errorf("%w: invalid ROLE", ErrCommandLineArgs)
}
formFactor, ok := vcsec.KeyFormFactor_value["KEY_FORM_FACTOR_"+strings.ToUpper(args["FORM_FACTOR"])]
if !ok {
return fmt.Errorf("%w: unrecognized FORM_FACTOR", ErrCommandLineArgs)
}
publicKey, err := protocol.LoadPublicKey(args["PUBLIC_KEY"])
if err != nil {
return fmt.Errorf("invalid public key: %s", err)
}
if err := car.SendAddKeyRequest(ctx, publicKey, role == "OWNER", vcsec.KeyFormFactor(formFactor)); err != nil {
return err
}
fmt.Printf("Sent add-key request to %s. Confirm by tapping NFC card on center console.\n", car.VIN())
return nil
},
},
"remove-key": &Command{
help: "Remove PUBLIC_KEY from vehicle whitelist",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
publicKey, err := protocol.LoadPublicKey(args["PUBLIC_KEY"])
if err != nil {
return fmt.Errorf("invalid public key: %s", err)
}
return car.RemoveKey(ctx, publicKey)
},
},
"rename-key": &Command{
help: "Change the human-readable metadata of PUBLIC_KEY to NAME, MODEL, KIND",
requiresAuth: false,
requiresFleetAPI: true,
args: []Argument{
Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"},
Argument{name: "NAME", help: "New human-readable name for the public key (e.g., Dave's Phone)"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
publicKey, err := protocol.LoadPublicKey(args["PUBLIC_KEY"])
if err != nil {
return fmt.Errorf("invalid public key: %s", err)
}
return acct.UpdateKey(ctx, publicKey, args["NAME"])
},
},
"get": &Command{
help: "GET an owner API http ENDPOINT. Hostname will be taken from -config.",
requiresAuth: false,
requiresFleetAPI: true,
args: []Argument{
Argument{name: "ENDPOINT", help: "Fleet API endpoint"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
reply, err := acct.Get(ctx, args["ENDPOINT"])
if err != nil {
return err
}
fmt.Println(string(reply))
return nil
},
},
"post": &Command{
help: "POST to ENDPOINT the contents of FILE. Hostname will be taken from -config.",
requiresAuth: false,
requiresFleetAPI: true,
args: []Argument{
Argument{name: "ENDPOINT", help: "Fleet API endpoint"},
},
optional: []Argument{
Argument{name: "FILE", help: "JSON file to POST"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var jsonBytes []byte
var err error
if filename, ok := args["FILE"]; ok {
jsonBytes, err = os.ReadFile(filename)
} else {
jsonBytes, err = io.ReadAll(os.Stdin)
}
if err != nil {
return err
}
reply, err := acct.Post(ctx, args["ENDPOINT"], jsonBytes)
// reply can be set where there's an error; typically a JSON blob providing details
if reply != nil {
fmt.Println(string(reply))
}
if err != nil {
return err
}
return nil
},
},
"list-keys": &Command{
help: "List public keys enrolled on vehicle",
requiresAuth: false,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
summary, err := car.KeySummary(ctx)
if err != nil {
return err
}
slot := uint32(0)
var details *vcsec.WhitelistEntryInfo
for mask := summary.GetSlotMask(); mask > 0; mask >>= 1 {
if mask&1 == 1 {
details, err = car.KeyInfoBySlot(ctx, slot)
if err != nil {
writeErr("Error fetching slot %d: %s", slot, err)
if errors.Is(err, context.DeadlineExceeded) {
return err
}
}
if details != nil {
fmt.Printf("%02x\t%s\t%s\n", details.GetPublicKey().GetPublicKeyRaw(), details.GetKeyRole(), details.GetMetadataForKey().GetKeyFormFactor())
}
}
slot++
}
return nil
},
},
"honk": &Command{
help: "Honk horn",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.HonkHorn(ctx)
},
},
"ping": &Command{
help: "Ping vehicle",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.Ping(ctx)
},
},
"flash-lights": &Command{
help: "Flash lights",
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.FlashLights(ctx)
},
},
"charging-set-limit": &Command{
help: "Set charge limit to PERCENT",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "PERCENT", help: "Charging limit"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
limit, err := strconv.Atoi(args["PERCENT"])
if err != nil {
return fmt.Errorf("error parsing PERCENT")
}
return car.ChangeChargeLimit(ctx, int32(limit))
},
},
"charging-start": &Command{
help: "Start charging",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.ChargeStart(ctx)
},
},
"charging-stop": &Command{
help: "Stop charging",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.ChargeStop(ctx)
},
},
"media-set-volume": &Command{
help: "Set volume",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "VOLUME", help: "Set volume (0.0-10.0"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
volume, err := strconv.ParseFloat(args["VOLUME"], 32)
if err != nil {
return fmt.Errorf("failed to parse volume")
}
return car.SetVolume(ctx, float32(volume))
},
},
"software-update-start": &Command{
help: "Start software update after DELAY",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{
name: "DELAY",
help: "Time to wait before starting update. Examples: 2h, 10m.",
},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
delay, err := time.ParseDuration(args["DELAY"])
if err != nil {
return fmt.Errorf("error parsing DELAY. Valid times are <n><unit>, where <n> is a number (decimals are allowed) and <unit> is 's, 'm', or 'h'")
// ...or 'ns'/'µs' if that's your cup of tea.
}
return car.ScheduleSoftwareUpdate(ctx, delay)
},
},
"software-update-cancel": &Command{
help: "Cancel a pending software update",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.CancelSoftwareUpdate(ctx)
},
},
"sentry-mode": &Command{
help: "Set sentry mode to STATE ('on' or 'off')",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "STATE", help: "'on' or 'off'"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var state bool
switch args["STATE"] {
case "on":
state = true
case "off":
state = false
default:
return fmt.Errorf("sentry mode state must be 'on' or 'off'")
}
return car.SetSentryMode(ctx, state)
},
},
"wake": &Command{
help: "Wake up vehicle",
requiresAuth: false,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.Wakeup(ctx)
},
},
"trunk-open": &Command{
help: "Open vehicle trunk. Note that trunk-close only works on certain vehicle types.",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.OpenTrunk(ctx)
},
},
"trunk-move": &Command{
help: "Toggle trunk open/closed. Closing is only available on certain vehicle types.",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.ActuateTrunk(ctx)
},
},
"trunk-close": &Command{
help: "Closes vehicle trunk. Only available on certain vehicle types.",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.CloseTrunk(ctx)
},
},
"frunk-open": &Command{
help: "Open vehicle frunk. Note that there's no frunk-close command!",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.OpenFrunk(ctx)
},
},
"charge-port-open": &Command{
help: "Open charge port",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.OpenChargePort(ctx)
},
},
"charge-port-close": &Command{
help: "Close charge port",
requiresAuth: true,
requiresFleetAPI: false,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.CloseChargePort(ctx)
},
},
"autosecure-modelx": &Command{
help: "Close falcon-wing doors and lock vehicle. Model X only.",
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
return car.AutoSecureVehicle(ctx)
},
},
"session-info": &Command{
help: "Retrieve session info for PUBLIC_KEY from DOMAIN",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"},
Argument{name: "DOMAIN", help: "'vcsec' or 'infotainment'"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
// See SeatPosition definition for controlling backrest heaters (limited models).
domains := map[string]protocol.Domain{
"vcsec": protocol.DomainVCSEC,
"infotainment": protocol.DomainInfotainment,
}
domain, ok := domains[args["DOMAIN"]]
if !ok {
return fmt.Errorf("invalid domain %s", args["DOMAIN"])
}
publicKey, err := protocol.LoadPublicKey(args["PUBLIC_KEY"])
if err != nil {
return fmt.Errorf("invalid public key: %s", err)
}
info, err := car.SessionInfo(ctx, publicKey, domain)
if err != nil {
return err
}
fmt.Printf("%s\n", info)
return nil
},
},
"seat-heater": &Command{
help: "Set seat heater at POSITION to LEVEL",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "SEAT", help: "<front|2nd-row|3rd-row>-<left|center|right> (e.g., 2nd-row-left)"},
Argument{name: "LEVEL", help: "off, low, medium, or high"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
// See SeatPosition definition for controlling backrest heaters (limited models).
seats := map[string]vehicle.SeatPosition{
"front-left": vehicle.SeatFrontLeft,
"front-right": vehicle.SeatFrontRight,
"2nd-row-left": vehicle.SeatSecondRowLeft,
"2nd-row-center": vehicle.SeatSecondRowCenter,
"2nd-row-right": vehicle.SeatSecondRowRight,
"3rd-row-left": vehicle.SeatThirdRowLeft,
"3rd-row-right": vehicle.SeatThirdRowRight,
}
position, ok := seats[args["SEAT"]]
if !ok {
return fmt.Errorf("invalid seat position")
}
levels := map[string]vehicle.Level{
"off": vehicle.LevelOff,
"low": vehicle.LevelLow,
"medium": vehicle.LevelMed,
"high": vehicle.LevelHigh,
}
level, ok := levels[args["LEVEL"]]
if !ok {
return fmt.Errorf("invalid seat heater level")
}
spec := map[vehicle.SeatPosition]vehicle.Level{
position: level,
}
return car.SetSeatHeater(ctx, spec)
},
},
"steering-wheel-heater": &Command{
help: "Set steering wheel mode to STATE ('on' or 'off')",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "STATE", help: "'on' or 'off'"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var state bool
switch args["STATE"] {
case "on":
state = true
case "off":
state = false
default:
return fmt.Errorf("steering wheel state must be 'on' or 'off'")
}
return car.SetSteeringWheelHeater(ctx, state)
},
},
"product-info": &Command{
help: "Print JSON product info",
requiresAuth: false,
requiresFleetAPI: true,
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
productsJSON, err := acct.Get(ctx, "api/1/products")
if err != nil {
return err
}
fmt.Println(string(productsJSON))
return nil
},
},
"auto-seat-and-climate": &Command{
help: "Turn on automatic seat heating and HVAC",
requiresAuth: true,
requiresFleetAPI: false,
args: []Argument{
Argument{name: "POSITIONS", help: "'L' (left), 'R' (right), or 'LR'"},
},
optional: []Argument{
Argument{name: "STATE", help: "'on' (default) or 'off'"},
},
handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
var positions []vehicle.SeatPosition
if strings.Contains(args["POSITIONS"], "L") {
positions = append(positions, vehicle.SeatFrontLeft)
}
if strings.Contains(args["POSITIONS"], "R") {
positions = append(positions, vehicle.SeatFrontRight)
}
if len(positions) != len(args["POSITIONS"]) {
return fmt.Errorf("invalid seat position")
}
enabled := true
if state, ok := args["STATE"]; ok && strings.ToUpper(state) == "OFF" {
enabled = false
}
return car.AutoSeatAndClimate(ctx, positions, enabled)
},
},
}

View file

@ -0,0 +1,170 @@
use scraper::{Element, ElementRef, Html, Selector};
use std::collections::HashMap;
use std::str::FromStr;
struct FleetApiSpec {
calls: HashMap<String, Call>,
}
// e.g. serialize to similar: vehicle-endpoints
#[derive(Debug, strum::EnumString)]
#[strum(serialize_all = "kebab-case")]
enum Category {
ChargingEndpoints,
PartnerEndpoints,
UserEndpoints,
VehicleCommands,
VehicleEndpoints,
}
#[derive(Debug, strum::EnumString)]
#[strum(serialize_all = "snake_case")]
enum Scope {
/// Profile Information
///
/// Contact information, home address, profile picture, and referral information.
UserData,
/// Vehicle Information
///
/// Vehicle live data, location, eligible upgrades, nearby superchargers, ownership, and service scheduling data.
VehicleDeviceData,
/// Vehicle Commands
///
/// Commands like add/remove driver, access Live Camera, unlock, wake up, remote start, and schedule software updates.
VehicleCmds,
/// Vehicle Charging Management
///
/// Vehicle charging history, billed amount, charging location, commands to schedule, and start/stop charging.
VehicleChargingCmds,
/// Energy Product Information
///
/// Energy flow history, saving forecast, tariff rates, grid import, calendar, site status, time of use, and ownership.
EnergyDeviceData,
/// Energy Product Commands
///
/// Commands like update storm mode.
EnergyCmds,
}
enum InRequestData {
Query,
Body,
}
struct Parameter {
name: String,
request: InRequestData,
var_type: String,
required: bool,
description: String,
}
struct Call {
name: String,
method: reqwest::Method,
url_definition: String,
description: String,
category: Category,
scopes: Vec<Scope>,
parameters: Vec<Parameter>,
request_example: String,
response_example: String,
}
pub fn parse(html: &str) -> () {
let document = Html::parse_document(html);
let content_selector = selector(".content h1");
let mut element = document.select(&content_selector).next().unwrap();
let mut category = None;
// Iterate over all the elements in the content section until we see a h1 or h2.
loop {
match element.value().name() {
"h1" => {
let category_name = element.value().id().unwrap();
category = Category::from_str(&category_name).ok();
}
"h2" => {
if category.is_some() {
let name = element.inner_html();
println!("{category:?} {name:?}");
// let call = parse_call(element);
}
}
_ => {}
}
let Some(next_element) = element.next_sibling_element() else {
println!("exiting...");
break;
};
element = next_element;
}
}
/// Return None if this is not an endpoint.
///
/// Will panic if it looks like an endpoint and has trouble parsing.
fn parse_call(element: ElementRef) -> Option<Call> {
let name = element.value().id().unwrap();
// <p><span class="endpoint"><code>POST /api/1/vehicles/{id}/command/auto_conditioning_start</code></span></p>
// This section determines if this is an endpoint or not.
let (fragment, element) = next(element);
let url = fragment.select(&selector("code")).next()?.inner_html();
if !url.starts_with("GET ") && !url.starts_with("POST ") {
return None;
}
let (method, url) = url.split_once(' ').unwrap();
println!("{} {}", method, url);
// <p>scopes: <em>vehicle_cmds</em></p>
let (fragment, element) = next(element);
let scopes = fragment
.select(&selector("em"))
.map(|e| e.inner_html())
.map(|e| Scope::from_str(&e))
.collect::<Vec<_>>();
// 4 <div class="highlight"> nodes containing example requests in different languages.
// TODO: Skip for now
let mut count = 0;
let mut element = element;
loop {
let (fragment, new_element) = next(element);
element = new_element;
if fragment
.select(&selector(r#"div[class="highlight"]"#))
.next()
.is_none()
{
break;
}
count += 1;
if count == 10 {
panic!("Too many examples");
}
}
if count == 0 && name != "api-status" {
panic!("No examples for {}", name);
}
None
}
fn next(element: ElementRef) -> (Html, ElementRef) {
let element = element.next_sibling_element().unwrap();
let html = Html::parse_fragment(&element.html());
(html, element)
}
fn selector(s: &str) -> Selector {
Selector::parse(s).unwrap()
}

View file

@ -0,0 +1,93 @@
mod fleet;
mod vehicle_command;
use clap::Parser;
use scraper::Element;
use std::path::PathBuf;
use std::str::FromStr;
use tracing::info;
const TIMDORR_URL: &str =
"https://raw.githubusercontent.com/timdorr/tesla-api/master/ownerapi_endpoints.json";
const TIMDORR_FILE: &str = "timdorr.json";
const VEHICLE_COMMAND_URL: &str = "https://raw.githubusercontent.com/teslamotors/vehicle-command/main/cmd/tesla-control/commands.go";
const VEHICLE_COMMAND_FILE: &str = "vehicle_command.go";
const FLEET_API_URL: &str = "https://developer.tesla.com/docs/fleet-api";
const FLEET_API_FILE: &str = "fleet.html";
#[derive(Parser, Debug)]
#[clap(author, version)]
struct Cli {
/// Use the cached html if exists, to avoid making requests.
#[clap(short, long)]
cached: bool,
#[clap(short = 'v', long)]
only_vehicle_command: bool,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let args = Cli::parse();
// let timorr = cache_fetch(TIMDORR_URL, TIMDORR_FILE, args.cache).await;
//
// let fleet_html = cache_fetch(
// FLEET_API_URL,
// FLEET_API_FILE,
// args.cache,
// )
// .await;
//
// let command_golang = cache_fetch(
// VEHICLE_COMMAND_URL,
// VEHICLE_COMMAND_FILE,
// args.cache,
// ).await;
let (timorr, fleet_html, command_golang) = tokio::join!(
cache_fetch(TIMDORR_URL, TIMDORR_FILE, args.cached),
cache_fetch(FLEET_API_URL, FLEET_API_FILE, args.cached),
cache_fetch(VEHICLE_COMMAND_URL, VEHICLE_COMMAND_FILE, args.cached)
);
let mut vehicle_command = true;
let mut fleet_api = true;
let mut timdorr = true;
if args.only_vehicle_command {
fleet_api = false;
timdorr = false;
}
if fleet_api {
fleet::parse(&fleet_html);
}
if vehicle_command {
vehicle_command::parse(&command_golang);
}
}
async fn cache_fetch(url: &str, filename: &str, cache: bool) -> String {
// Write to where this project root is, not in the parent project.
let mut path = PathBuf::new();
path.push(env!("CARGO_MANIFEST_DIR"));
path.push("cached");
path.push(filename);
if cache && path.exists() {
info!("Using cache: {path:?}");
return std::fs::read_to_string(path).unwrap();
}
info!("Fetching {url} -> {path:?}");
let response = reqwest::get(url).await.unwrap();
let html = response.text().await.unwrap();
std::fs::write(path, &html).unwrap();
html
}

View file

@ -0,0 +1,209 @@
use nom::branch::alt;
use nom::bytes::complete::{tag, take_until, take_while};
use nom::character::complete::{char, line_ending, space0, space1, tab};
use nom::combinator::opt;
use nom::multi::{many0, many1};
use nom::IResult;
use tracing::{trace, warn};
pub fn parse(s: &str) -> () {
// Seek all the way to: var commands = map[string]*Command{\n
// Afterwards has the first map entry.
let commands_start = "var commands = map[string]*Command{\n";
let offset = s.find(commands_start).unwrap();
let s = &s[offset + commands_start.len()..];
let (go, entries) = many1(map_entry)(s).unwrap();
dbg!(&entries);
warn!("todo: parse")
}
#[derive(Debug)]
struct MapEntry {
endpoint: String,
help: String,
// requires_auth: bool,
// requires_fleet: bool,
}
fn map_entry(s: &str) -> IResult<&str, MapEntry> {
// "unlock": &Command{
// help: "Unlock vehicle",
// requiresAuth: true,
// requiresFleetAPI: false,
// args: []Argument{
// Argument{name: "TEMP", help: "Desired temperature (e.g., 70f or 21c; defaults to Celsius)"},
// Argument{name: "ROLE", help: "One of: owner, driver"},
// },
// handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error {
// return car.Unlock(ctx)
// },
// },
short_trace("--- map entry ---", s);
// endpoint
short_trace("endpoint", s);
let (s, _) = ignore_whitespace(s)?;
let (s, endpoint) = quoted_string(s)?;
let (s, _) = until_eol(s)?;
// help
short_trace("help", s);
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag("help:")(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, help) = quoted_string(s)?;
let (s, _) = tag(",")(s)?;
// requiresAuth
short_trace("requiresAuth", s);
let (s, requires_auth) = bool_field_or_false(s, "requiresAuth:")?;
// requiresFleetAPI
short_trace("requiresFleetAPI", s);
let (s, requires_fleet) = bool_field_or_false(s, "requiresFleetAPI:")?;
// required args
short_trace("required args", s);
let (s, required_args) = args(s, "args: []Argument{")?;
// optional args
short_trace("optional args", s);
let (s, optional_args) = args(s, "optional: []Argument{")?;
// check and ignore the handler, as there's not really much data we can take out of it.
let (s, _) = ignore_whitespace(s)?;
let (s, _) = take_until("},")(s)?;
let (s, _) = tag("},")(s)?;
// And the end of the record...
let (s, _) = take_until("},")(s)?;
let (s, _) = tag("},")(s)?;
dbg!(endpoint, help, requires_auth, requires_fleet);
Ok((
s,
MapEntry {
endpoint: endpoint.to_string(),
help: help.to_string(),
},
))
}
/// Ignore the quotes and return the inner string.
/// e.g. "unlock"
fn quoted_string(s: &str) -> IResult<&str, &str> {
short_trace("quoted string", s);
let (s, _) = char('"')(s)?;
let (s, string) = take_until("\"")(s)?;
let (s, _) = char('"')(s)?;
Ok((s, string))
}
fn ignore_whitespace(s: &str) -> IResult<&str, ()> {
short_trace("ignore whitespace", s);
let (s, ws) = many0(alt((tag("\t"), space1, line_ending)))(s)?;
short_trace("ignore whitespace afterwards", s);
Ok((s, ()))
}
fn until_eol(s: &str) -> IResult<&str, &str> {
short_trace("eol", s);
let (s, line) = take_until("\n")(s)?;
let (s, _) = line_ending(s)?;
short_trace("eol afterwards", s);
Ok((s, line))
}
fn str_to_bool(s: &str) -> IResult<&str, bool> {
short_trace("bool", s);
let (s, bool_str) = alt((tag("true"), tag("false")))(s)?;
let bool = match bool_str {
"true" => true,
"false" => false,
_ => unreachable!(),
};
short_trace("bool afterwards", s);
Ok((s, bool))
}
/// If the field isn't there, assume false.
fn bool_field<'a>(field_tag: &str) -> impl Fn(&'a str) -> IResult<&'a str, bool> + '_ {
return move |s: &str| -> IResult<&'a str, bool> {
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag(field_tag)(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, value) = str_to_bool(s)?;
let (s, _) = tag(",")(s)?;
Ok((s, value))
};
}
fn bool_field_or_false<'a>(s: &'a str, field_tag: &str) -> IResult<&'a str, bool> {
let (s, value) = opt(bool_field(field_tag))(s)?;
return Ok((s, value.unwrap_or(false)));
}
struct Arg {
name: String,
help: String,
}
fn args<'a>(s: &'a str, field_tag: &str) -> IResult<&'a str, Vec<Arg>> {
short_trace("args", s);
let (s, _) = ignore_whitespace(s)?;
let (s, maybe_field) = opt(tag(field_tag))(s)?;
if maybe_field.is_none() {
trace!("no arg record");
return Ok((s, vec![]));
}
let (s, _) = ignore_whitespace(s)?;
let (s, args) = many1(arg)(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag("},")(s)?;
short_trace("args afterwards", s);
Ok((s, args))
}
fn arg(s: &str) -> IResult<&str, Arg> {
short_trace("arg", s);
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag("Argument{")(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag("name:")(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, name) = quoted_string(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag(",")(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag("help:")(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, help) = quoted_string(s)?;
let (s, _) = opt(tag(","))(s)?;
let (s, _) = ignore_whitespace(s)?;
let (s, _) = tag("},")(s)?;
short_trace("arg afterwards", s);
Ok((
s,
Arg {
name: name.to_string(),
help: help.to_string(),
},
))
}
fn short_trace(prefix: &str, s: &str) {
let mut max_len_left = 20;
if s.len() < max_len_left {
max_len_left = s.len();
}
trace!("{}: {:?}...", prefix, &s[0..max_len_left])
}