mirror of
https://github.com/netfun2000/hipudding-teslamate.git
synced 2026-02-27 09:44:28 +08:00
* Add ability to enable and disable cars * Add default value for enabled in car_settings. * Fix unit tests. * Fix formatting. * Update text to be more descriptive * Update lables.
302 lines
8.6 KiB
Elixir
302 lines
8.6 KiB
Elixir
defmodule TeslaMate.Import do
|
|
use GenStateMachine
|
|
|
|
require Logger
|
|
|
|
alias TeslaMate.Settings.CarSettings
|
|
alias TeslaMate.Vehicles.Vehicle
|
|
alias TeslaMate.{Vehicles, Repair, Log}
|
|
alias TeslaMate.Log.{Car, State}
|
|
|
|
alias __MODULE__.{Status, LineParser, FakeApi, CSV}
|
|
|
|
defstruct(
|
|
path: nil,
|
|
files: [],
|
|
timezone: :utc,
|
|
error: nil,
|
|
completed: MapSet.new(),
|
|
car: nil,
|
|
pids: %{},
|
|
deps: %{}
|
|
)
|
|
|
|
alias __MODULE__, as: Data
|
|
|
|
defmodule Status do
|
|
defstruct(state: :idle, message: nil, files: [])
|
|
|
|
def into(state, %Data{files: files, completed: completed}) do
|
|
files =
|
|
Enum.map(files, fn %{date: date} = file ->
|
|
complete = MapSet.member?(completed, date)
|
|
Map.put(file, :complete, complete)
|
|
end)
|
|
|
|
case state do
|
|
{:error, reason} -> %__MODULE__{state: :error, message: reason, files: files}
|
|
state when is_atom(state) -> %__MODULE__{state: state, files: files}
|
|
end
|
|
end
|
|
end
|
|
|
|
@name __MODULE__
|
|
@topic "#{@name}/state"
|
|
|
|
def start_link(opts) do
|
|
GenStateMachine.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, @name))
|
|
end
|
|
|
|
def run(timezone), do: GenStateMachine.call(@name, {:run, timezone})
|
|
def running?, do: GenStateMachine.call(@name, :running?)
|
|
def enabled?, do: is_pid(Process.whereis(@name))
|
|
def valid_file_name?(fname), do: parse_fname(fname) != nil
|
|
def get_status, do: GenStateMachine.call(@name, :get_status)
|
|
def reload_directory, do: GenStateMachine.call(@name, :reload_directory)
|
|
def subscribe, do: Phoenix.PubSub.subscribe(TeslaMate.PubSub, @topic)
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
Process.flag(:trap_exit, true)
|
|
path = Keyword.fetch!(opts, :directory)
|
|
{:ok, :idle, %Data{path: path}, {:next_event, :internal, :read_directory}}
|
|
end
|
|
|
|
## Calls
|
|
|
|
@impl true
|
|
def handle_event({:call, from}, {:run, tz}, :idle, data) do
|
|
{:next_state, :running, %Data{data | timezone: tz},
|
|
[
|
|
{:reply, from, :ok},
|
|
{:next_event, :internal, :broadcast},
|
|
{:next_event, :internal, :import}
|
|
]}
|
|
end
|
|
|
|
def handle_event({:call, from}, {:run, _tz}, _, _data) do
|
|
{:keep_state_and_data, {:reply, from, {:error, :not_allowed}}}
|
|
end
|
|
|
|
def handle_event({:call, from}, :running?, state, _data) do
|
|
{:keep_state_and_data, {:reply, from, state == :running}}
|
|
end
|
|
|
|
def handle_event({:call, from}, :get_status, state, data) do
|
|
{:keep_state_and_data, {:reply, from, Status.into(state, data)}}
|
|
end
|
|
|
|
def handle_event({:call, from}, :reload_directory, _state, _data) do
|
|
{:keep_state_and_data, [{:reply, from, :ok}, {:next_event, :internal, :read_directory}]}
|
|
end
|
|
|
|
## Internal
|
|
|
|
def handle_event(:internal, :broadcast, state, data) do
|
|
:ok = Phoenix.PubSub.broadcast(TeslaMate.PubSub, @topic, Status.into(state, data))
|
|
:keep_state_and_data
|
|
end
|
|
|
|
def handle_event(:internal, :read_directory, :idle, %Data{path: path} = data) do
|
|
case File.ls(path) do
|
|
{:error, reason} ->
|
|
{:next_state, {:error, reason}, data, {:next_event, :internal, :broadcast}}
|
|
|
|
{:ok, files} ->
|
|
files =
|
|
files
|
|
|> Enum.map(fn n -> %{date: parse_fname(n), path: Path.join([path, n])} end)
|
|
|> Enum.reject(fn %{date: date} -> is_nil(date) end)
|
|
|> Enum.sort_by(fn %{date: date} -> date end)
|
|
|
|
{:keep_state, %Data{data | files: files}, {:next_event, :internal, :broadcast}}
|
|
end
|
|
end
|
|
|
|
def handle_event(:internal, :read_directory, _state, _data) do
|
|
:keep_state_and_data
|
|
end
|
|
|
|
def handle_event(:internal, :import, :running, %Data{files: files} = data) do
|
|
Logger.info("Importing #{length(files)} file(s) ...")
|
|
|
|
case create_event_streams(data) do
|
|
{:error, reason} ->
|
|
{:next_state, {:error, reason}, data, {:next_event, :internal, :broadcast}}
|
|
|
|
{:ok, streams} ->
|
|
car = create_car(streams)
|
|
{:ok, streams} = create_event_streams(data, car)
|
|
|
|
:ok = Log.complete_current_state(car)
|
|
|
|
date_limit =
|
|
with %State{start_date: date} <- Log.get_earliest_state(car) do
|
|
date
|
|
end
|
|
|
|
api_name = :"api_#{car.name}"
|
|
|
|
{:ok, api} =
|
|
FakeApi.start_link(
|
|
name: api_name,
|
|
event_streams: streams,
|
|
date_limit: date_limit,
|
|
pid: self()
|
|
)
|
|
|
|
{:ok, veh} =
|
|
Vehicle.start_link(
|
|
name: :"import_#{car.name}",
|
|
car: car,
|
|
import?: true,
|
|
deps_api: {FakeApi, api_name}
|
|
)
|
|
|
|
{:keep_state, %Data{data | car: car, pids: %{veh: veh, api: api}},
|
|
{:next_event, :internal, :broadcast}}
|
|
end
|
|
end
|
|
|
|
## Info
|
|
|
|
def handle_event(:info, {:done, chunk}, :running, %Data{completed: completed} = data) do
|
|
:ok = Repair.trigger_run()
|
|
|
|
{:keep_state, %Data{data | completed: MapSet.put(completed, chunk)},
|
|
{:next_event, :internal, :broadcast}}
|
|
end
|
|
|
|
def handle_event(:info, :done, :running, %Data{car: car, pids: %{api: api, veh: veh}} = data) do
|
|
Logger.info("Import complete!")
|
|
|
|
true = Process.exit(veh, :kill)
|
|
true = Process.exit(api, :normal)
|
|
|
|
:ok = Log.complete_current_state(car)
|
|
:ok = Log.create_current_state(car)
|
|
:ok = Repair.trigger_run()
|
|
|
|
{:next_state, :complete, data, {:next_event, :internal, :broadcast}}
|
|
end
|
|
|
|
def handle_event(:info, {:EXIT, _from, :normal}, _state, _data), do: :keep_state_and_data
|
|
def handle_event(:info, {:EXIT, _from, :killed}, _state, _data), do: :keep_state_and_data
|
|
|
|
def handle_event(:info, {:EXIT, _from, reason}, _state, data) do
|
|
Logger.warning("Import failed: #{inspect(reason, pretty: true)}")
|
|
{:next_state, {:error, reason}, data, {:next_event, :internal, :broadcast}}
|
|
end
|
|
|
|
## Private
|
|
|
|
defp parse_fname(name) do
|
|
case name do
|
|
<<m::binary-size(2), y::binary-size(4), ".csv"::bitstring>> ->
|
|
parse_date(y, m)
|
|
|
|
<<"TeslaFi"::bitstring, m::binary-size(2), y::binary-size(4), ".csv"::bitstring>> ->
|
|
parse_date(y, m)
|
|
|
|
<<"TeslaFi"::bitstring, m::binary-size(1), y::binary-size(4), ".csv"::bitstring>> ->
|
|
parse_date(y, m)
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp parse_date(year, month) do
|
|
with {year, ""} <- Integer.parse(year),
|
|
{month, ""} <- Integer.parse(month) do
|
|
[year, month]
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp create_event_streams(%Data{files: files, timezone: tz}, car \\ nil) do
|
|
alias TeslaApi.Vehicle.State.Drive
|
|
alias TeslaApi.Vehicle, as: Veh
|
|
|
|
try do
|
|
event_streams =
|
|
files
|
|
|> Enum.sort_by(fn %{date: date} -> date end)
|
|
|> Enum.map(fn %{date: date, path: path} ->
|
|
path
|
|
|> File.stream!(read_ahead: 64 * 4096)
|
|
|> CSV.parse()
|
|
|> case do
|
|
{:error, :unsupported_delimiter} ->
|
|
raise "Unsupported delimiter"
|
|
|
|
{:error, :no_contents} ->
|
|
{date, Stream.map([], & &1)}
|
|
|
|
{:ok, rows} ->
|
|
stream =
|
|
rows
|
|
|> Task.async_stream(&LineParser.parse(&1, tz), timeout: :infinity, ordered: true)
|
|
|> Stream.map(fn {:ok, vehicle} -> vehicle end)
|
|
|> Stream.filter(fn
|
|
%Veh{state: "unknown"} ->
|
|
false
|
|
|
|
%Veh{drive_state: %Drive{timestamp: nil}} ->
|
|
false
|
|
|
|
%Veh{vin: vin, vehicle_id: vid, id: eid} = v
|
|
when car != nil and nil not in [vin, vid, eid] and
|
|
vin != car.vin and vid != car.vid and eid != car.eid ->
|
|
Logger.warning(
|
|
"'#{path}' contains data for more than one vehicle: #{car.name}" <>
|
|
" -> #{v.display_name}!"
|
|
)
|
|
|
|
throw(:vehicle_changed)
|
|
|
|
%Veh{state: "online", drive_state: %Drive{} = d} ->
|
|
d.latitude != nil and d.longitude != nil
|
|
|
|
%Veh{} ->
|
|
true
|
|
end)
|
|
|
|
{date, stream}
|
|
end
|
|
end)
|
|
|
|
{:ok, event_streams}
|
|
rescue
|
|
e in File.Error -> {:error, e.reason}
|
|
e -> {:error, e}
|
|
end
|
|
end
|
|
|
|
defp create_car([]), do: raise("vehicle data is incomplete")
|
|
|
|
defp create_car([{_date, %Stream{} = stream} | rest]) do
|
|
alias TeslaApi.Vehicle, as: Veh
|
|
|
|
stream
|
|
|> Enum.find(fn %Veh{} = v -> v.vin != nil and v.vehicle_id != nil and v.id != nil end)
|
|
|> case do
|
|
nil ->
|
|
create_car(rest)
|
|
|
|
vehicle ->
|
|
car = Vehicles.create_or_update!(vehicle)
|
|
|
|
settings = %CarSettings{
|
|
suspend_min: 0,
|
|
suspend_after_idle_min: 99999,
|
|
use_streaming_api: false,
|
|
enabled: true
|
|
}
|
|
|
|
%Car{car | settings: settings}
|
|
end
|
|
end
|
|
end
|