Nature Cloud APIとTesla Fleet APIでEV充電を最適化してみた

に公開

我が家には太陽光パネルが設置されており、余剰電力で充電できる合理性からテスラ モデルYに乗っています。
テスラにはパワーウォールという住宅用蓄電池がラインアップされており、テスラ車両を余剰電力"のみ"を利用して充電する機能が備わっています。
しかしながら日本では国産の太陽光パネルや蓄電池を利用している方が多く、国産太陽光パネルとテスラの組み合わせでは余剰電力"のみ"を利用した充電の仕組みは存在しません。
これを解決するべく、Nature Cloud APIとTesla Fleet APIを利用して余剰電力"のみ"を利用して充電できるようにしたのでまとめます。

モチベーション

テスラのパワーウォールに備わっている余剰電力"のみ"を利用したEV充電を国産太陽光パネルを利用した住宅でも実現したい!

どうやって解決した

Nature Remo Eを利用してスマートメーターから電力値を取得し、最適な充電電流を計算してTesla Fleet APIを利用して充電開始/停止と充電電流を制御できるようにしました。

技術選定

https://zenn.dev/r_kaga/articles/c84a6af89e3020

こちらの記事に影響を受けて、AIコーディングエージェントによる手助けを受けられる & 運用コストを下げられる構成にしました。
とはいえ、今回はTesla Fleet APIの都合により2つの言語を利用して実装しました。理由は後述します。

HTTPサーバ

  • Typescript
  • hono
  • zod
  • drizzle
  • vitest

Tesla Fleet APIプロキシ

インフラ

  • Cloudflare Workers
  • Cloudflare Workers Static Assets
  • Cloudflare D1
  • Cloudflare Durable Object
  • Cloudflare Containers

なぜ2言語に?

Tesla Fleet APIで充電に関する制御を行うとき、専用のプロトコルでリクエストを送信する必要がありました。しかしながらプロトコルが公開されておらず、唯一これを解決できるvehicle-commandなるライブラリが存在するものの、Go言語での実装に制限されてしまいました。
じゃあ全部Goで書けばいいじゃん! というご意見もあるかと思いますが、今回はon distributionな技術選定としてTypescriptの採用を譲れなかったこと、時を同じくしてCloudflare ContainersというDockerコンテナを実行することができるサービスがローンチされたことでTesla Fleet APIに対するリクエストだけGoコンテナを経由させる作戦にしました。

図は完成したインフラアーキテクチャを示しています。

Nature Cloud API

Nature Cloud APIからスマートメーターを流れる電力量を取得します。

https://developer.nature.global/docs/how-to-calculate-energy-data-from-smart-meter-values/

Nature Cloud APIの詳細は解説は割愛しますが、今回のテーマである余剰電力のみでEV充電を制御する際、最も難しいポイントがスマートメーターから取得する電力値にはテスラの充電電力も含まれてしまうという点でした。
Nature Remo Eは、スマートメーターからEchonet Liteプロトコルを通じて瞬時電力値を取得します。この値は以下のような意味を持ちます。

  • 正の値: 買電中(電力会社から電気を買っている状態)
  • 負の値: 売電中(余剰電力を電力会社に売っている状態)

一見シンプルに見えますが、ここに大きな落とし穴があります。
例えば、以下のような状況を考えてみましょう。

太陽光発電: 5000W
家庭消費: 1000W
テスラ充電: 2000W (10A @ 200V)

この場合、スマートメーターが示す瞬時電力は以下のようになります。

5000W - 1000W - 2000W = 2000W (売電中)

つまり、既にテスラが充電している2000Wは、スマートメーターの値には反映されないのです。

充電電流の算出ロジック

前述のとおり、充電電流は単純なロジックでは算出できないことがわかりました。
もし「余剰電力 ÷ 電圧 = 充電電流」という単純な計算で充電電流を設定してしまうとどうなるでしょうか。

// ❌ 単純すぎる実装例
const amps = Math.floor(surplusWatts / voltage);
// 2000W / 200V = 10A

この実装では、次のような問題が発生します。

問題1: 無限ループの可能性

ジョブが再度実行されたとき

  • テスラは10Aで充電中 (2000W)
  • スマートメーターは同じく2000Wの余剰を示す
  • 再び10Aを設定...

というように、同じ値が設定され続けることになります。
一見問題なさそうですが、太陽光発電が増えても充電電流が増えない状態になってしまいます。

問題2: 太陽光減少時の誤った充電停止

さらに悪いことに、太陽光発電が急に減少した場合を考えてみましょう。

太陽光発電: 3000W に減少
家庭消費: 1000W
テスラ充電: 2000W (10A @ 200V)

スマートメーターの値:

3000W - 1000W - 2000W = 0W (余剰なし)

この状態で単純に「余剰電力がゼロだから充電停止」としてしまうと、本来なら充電電流を下げるべきなのに完全に停止してしまいます。
さらに買電に転じた場合(-500Wなど)、計算結果がマイナスになってしまい、適切な制御ができません。

解決策: 充電状態に応じた制御ロジック

この問題を解決するため、充電状態に応じて異なる制御ロジックを採用しました。

export const nextChargingAmps = (params: NextChargingAmpsParams): NextChargingAmpsOutput => {
  // 余剰電力から調整値を計算
  const amps_adjustment = Math.floor(params.surplusWatts / params.chargerVoltage);

  let amps: number;
  switch (params.currentChargingState) {
    case "Stopped":
      // Stopped状態では余剰電力のみから計算
      amps = Math.max(0, Math.min(amps_adjustment, params.chargeCurrentRequestMax));
      if (amps < 5) {
        return { nextChargingState: "Stopped", amps };
      }
      // Stopped状態ではchargeVoltageが信用できないので5Aからはじめる
      return { nextChargingState: "Charging", amps: 5 };

    case "Charging":
      // Charging状態では現在の充電電流に調整値を加える
      amps = Math.max(0, Math.min(params.chargeCurrentRequest + amps_adjustment, params.chargeCurrentRequestMax));
      if (amps < 5) {
        // 次回StoppedからChargingに遷移するときにいきなり大電流にならないように5Aに設定する
        return { nextChargingState: "Stopped", amps: 5 };
      }
      return { nextChargingState: "Charging", amps };

    default:
      return { nextChargingState: params.currentChargingState, amps: params.chargeCurrentRequest };
  }
};

この実装では、充電状態によって以下のように異なる制御を行います。

Stopped状態: 余剰電力のみから計算

充電が停止している状態では、余剰電力のみから充電電流を計算します。

余剰: 2000W
調整: 10A (2000W / 200V)
→ 5A以上なので充電開始(5Aから開始)

ただし、充電停止中はchargerVoltageが不正確な値を返すことがあるため、計算上5A以上の余剰があっても一律5Aから開始します。これにより、安全かつ確実に充電を開始できます。

Charging状態: 差分制御による動的調整

充電中の状態では、現在の充電電流をベースに余剰電力の変動分を加減算します。

太陽光が増えた場合

余剰: 1000W増加
調整: +5A (1000W / 200V)
現在: 10A → 次回: 15A

太陽光発電が増えると、その増加分だけ充電電流を増やすことができます。

太陽光が減った場合

余剰: -1000W (買電に転じた)
調整: -5A (-1000W / 200V)
現在: 10A → 次回: 5A

太陽光発電が減り買電に転じた場合でも、その減少分だけ充電電流を減らすことで、買電を最小限に抑えられます。

余剰が変わらない場合

余剰: 0W
調整: 0A
現在: 10A → 次回: 10A (維持)

余剰電力が変わらなければ、現在の充電電流を維持します。

充電状態の制御

主なポイント:

  • 5A未満の場合: テスラの最小充電電流は5Aのため、計算結果が5A未満の場合は充電を停止します
  • Stopped→Charging遷移: 余剰電力が十分にあっても、安全のため5Aから開始します
  • Charging→Stopped遷移: 充電電流が5A未満になると停止しますが、次回の充電開始に備えて5Aを設定しておきます
  • 最大値制限: 車両やコンセントの制限を超えないよう、常に上限値でクリップします

この方式により、スマートメーターの値にテスラの充電分が含まれていても、充電状態に応じた適切な制御が可能になります。

また、Cloudflare Workersのtriggers.cronsを使って5分ごとに実行することで、太陽光発電の変動に追従しながら、常に余剰電力のみで充電できる状態を維持します。

// wrangler.jsonc
{
  "triggers": {
    "crons": [
      "*/5 * * * *" // 5分ごとに実行
    ]
  }
}

Tesla Fleet API

算出した充電電流をもとに、Tesla Fleet APIを使ってテスラ車両を制御します。
ここで、先述した「なぜ2言語に?」の話が関係してきます。

vehicle-commandライブラリ

Tesla Fleet APIで充電制御を行う際、通常のREST APIとは異なる専用プロトコルでのリクエストが必要になります。このプロトコルは公開されておらず、Teslaが公式に提供しているvehicle-commandというGoライブラリを使うことが唯一の解決策でした。

vehicle-commandライブラリは以下のような手順で車両と通信します。

// 1. 秘密鍵を読み込み
privateKey, err := protocol.LoadPrivateKey(privateKeyPath)

// 2. アカウント作成
account, err := account.New(accessToken, USER_AGENT)

// 3. 車両取得
vehicle, err := account.GetVehicle(ctx, vin, privateKey, nil)

// 4. 車両に接続
vehicle.Connect(ctx)
defer vehicle.Disconnect()

// 5. セッション開始
vehicle.StartSession(ctx, nil)

// 6. 充電コマンド実行
vehicle.ChargeStart(ctx)           // 充電開始
vehicle.SetChargingAmps(ctx, amps) // 充電電流設定
vehicle.ChargeStop(ctx)            // 充電停止

このライブラリはGo専用のため、TypeScriptから直接利用することができません。

Cloudflare Containersでの解決

この問題を解決するため、Cloudflare Containersを活用しました。

Cloudflare Containersは、Dockerコンテナを実行できるサービスで、2025年にベータ版がローンチされました。これにより、Go言語で書かれたvehicle-commandラッパーをコンテナ化し、TypeScriptのWorkerから呼び出す構成が実現できました。

Goプロキシサーバの実装

vehicle-commandをプロキシするためにGo言語でシンプルなHTTPサーバを実装しました。

mux.HandleFunc("/tesla-vehicle-command", func(w http.ResponseWriter, r *http.Request) {
    req := TeslaVehicleCommandRequest{}
    json.NewDecoder(r.Body).Decode(&req)

    client.WithVehicle(
        ctx,
        req.AccessToken,
        req.VIN,
        func(ctx context.Context, vehicle *vehiclePkg.Vehicle) error {
            // 充電開始が必要なら実行
            if req.ChargeStart {
                client.ChargeStart(ctx, vehicle)
            }
            // 充電電流設定が必要なら実行
            if req.SetChargingAmps {
                client.SetChargingAmps(ctx, vehicle, req.Amps)
            }
            // 充電停止が必要なら実行
            if req.ChargeStop {
                client.ChargeStop(ctx, vehicle)
            }
            return nil
        })
})

リクエストボディで以下のパラメータを受け取ります:

{
  access_token: string,
  vin: string,
  amps: number,
  charge_start?: boolean,
  set_charging_amps?: boolean,
  charge_stop?: boolean,
}

TypeScriptからの呼び出し

Cloudflare Workersからは起動されたコンテナに対してリクエストを送ります。

const container = getContainer(env.TESLA_VEHICLE_COMMAND_PROXY);

const resp = await container.fetch(`http://localhost:4000/tesla-vehicle-command`, {
  headers: {
    "Content-Type": "application/json",
  },
  method: "POST",
  body: JSON.stringify({
    access_token: accessToken,
    vin: vin,
    amps: amps,
    charge_start: true,
    set_charging_amps: true,
  }),
});

充電状態の遷移制御

算出した充電電流と現在の充電状態に応じて、4パターンの状態遷移を制御します:

1. Stopped → Stopped (充電停止のまま)

余剰電力が不足している場合。充電は開始しませんが、次回の充電開始に備えて充電電流だけを設定しておきます。

case "Stopped -> Stopped":
  await container.fetch(`http://localhost:4000/tesla-vehicle-command`, {
    headers: {
      "Content-Type": "application/json",
    },
    method: "POST",
    body: JSON.stringify({
      access_token: new TextDecoder().decode(teslaCredential.accessToken),
      vin: env.TESLA_VIN,
      amps: amps,
      set_charging_amps: true,
    }),
  });
  break;

2. Stopped → Charging (充電開始)

余剰電力が十分にある場合、充電電流を設定してから充電を開始します。

case "Stopped -> Charging":
  await container.fetch(`http://localhost:4000/tesla-vehicle-command`, {
    // ...
    body: JSON.stringify({
      access_token: accessToken,
      vin: vin,
      amps: amps,
      charge_start: true,
      set_charging_amps: true,
    }),
  });
  break;

3. Charging → Charging (充電継続)

充電中で、余剰電力の変動に応じて充電電流を調整します。

case "Charging -> Charging":
  await container.fetch(`http://localhost:4000/tesla-vehicle-command`, {
    // ...
    body: JSON.stringify({
      access_token: accessToken,
      vin: vin,
      amps: amps,
      set_charging_amps: true,
    }),
  });
  break;

4. Charging → Stopped (充電停止)

余剰電力が不足してきた場合、充電電流を設定してから充電を停止します。

case "Charging -> Stopped":
  await container.fetch(`http://localhost:4000/tesla-vehicle-command`, {
    // ...
    body: JSON.stringify({
      access_token: accessToken,
      vin: vin,
      amps: amps,
      set_charging_amps: true,
      charge_stop: true,
    }),
  });
  break;

この4パターンの制御により、太陽光発電の変動に応じて自動的に充電を開始・停止・調整できるようになりました。

まとめ

Nature Cloud APIとTesla Fleet APIを組み合わせることで、国産太陽光パネルでもテスラ車両を余剰電力のみで充電できるシステムを構築しました。

技術的なポイントは以下のとおりです。

  1. 差分制御: スマートメーターの値にテスラの充電分が含まれる問題を、現在の充電電流に調整値を加減算することで解決
  2. Goコンテナの活用: vehicle-commandライブラリの制約をCloudflare Containersで解決し、TypeScriptとGoのハイブリッド構成を実現
  3. 状態遷移制御: 4パターンの充電状態遷移を適切に制御することで、安定した充電を実現
  4. サーバレス構成: Cloudflare Workers + Containers + D1で運用コストを最小化

国産太陽光パネルでも余剰電力のみでの充電により、電気代の節約が可能になりました。

このプロダクトには「wattson」という名前をつけました。「余剰なwattを損しない」という意味と、シャーロックホームズの助手であるワトソン教授のように、余剰電力活用のよき助手となるようにという想いを込めています。
一般公開にはまだまだやることがたくさんありますが、同じような悩みを持つ方にこのプロダクトが届いてくれたら嬉しいです。

Discussion