lib/nerves_time/rtc/nxp/pca8565.ex

defmodule NervesTime.RTC.NXP.PCA8565 do
  @moduledoc """
  datasheet: https://www.nxp.com/docs/en/data-sheet/PCA8565.pdf
  """

  @behaviour NervesTime.RealTimeClock
  import NervesTime.RealTimeClock.BCD

  alias Circuits.I2C

  @default_bus_name "i2c-1"
  @default_address 0x51

  @register_control <<0x00>>

  @register_seconds <<0x02>>
  # @register_minutes <<0x03>>
  # @register_hours <<0x04>>
  # @register_days <<0x05>>
  # @register_weekdays <<0x06>>
  # @register_months <<0x07>>
  # @register_years <<0x08>>

  @type address :: pos_integer()

  @type state :: %{
          i2c: I2C.Bus.t(),
          bus_name: String.t(),
          address: address()
        }

  @doc false
  @impl NervesTime.RealTimeClock
  def init(args) do
    bus_name = Keyword.get(args, :bus_name, @default_bus_name)
    address = Keyword.get(args, :address, @default_address)

    with {:ok, i2c} <- I2C.open(bus_name),
         true <- rtc_available?(i2c, address) do
      {:ok, %{i2c: i2c, bus_name: bus_name, address: address}}
    else
      {:error, _} = error ->
        error

      error ->
        {:error, error}
    end
  end

  @impl NervesTime.RealTimeClock
  def terminate(_state), do: :ok

  @impl NervesTime.RealTimeClock
  def set_time(state, now) do
    _ = set_time_to_rtc(state, now)
    state
  end

  @impl NervesTime.RealTimeClock
  def get_time(state) do
    with {:ok, time} <- get_time_from_rtc(state) do
      {:ok, time, state}
    else
      _ -> {:unset, state}
    end
  end

  @spec set_time_to_rtc(state, NaiveDateTime.t()) :: :ok | {:error, term()}
  defp set_time_to_rtc(state, %NaiveDateTime{} = date_time) do
    I2C.write(state.i2c, state.address, [
      @register_seconds,
      time_to_registers(date_time)
    ])
  end

  @spec get_time_from_rtc(state) :: {:ok, NaiveDateTime.t()} | {:error, term()}
  defp get_time_from_rtc(state) do
    with {:ok, registers} <-
           I2C.write_read(state.i2c, state.address, @register_seconds, 7) do
      {:ok, registers_to_time(registers)}
    end
  end

  @spec rtc_available?(I2C.Bus.t(), address) :: boolean()
  defp rtc_available?(i2c, address) do
    case I2C.write_read(i2c, address, @register_control, 1) do
      {:ok, ok} when byte_size(ok) == 1 ->
        true

      {:error, :i2c_nak} ->
        false
    end
  end

  defp time_to_registers(%NaiveDateTime{} = date_time) do
    second_bcd = from_integer(date_time.second)
    minute_bcd = from_integer(date_time.minute)
    hour_bcd = from_integer(date_time.hour)
    day_bcd = from_integer(date_time.day)

    weekday_bcd =
      Calendar.ISO.day_of_week(date_time.year, date_time.month, date_time.day, :sunday)
      |> then(fn {day_of_week, 1, 7} -> day_of_week - 1 end)
      |> from_integer()

    month_bcd = from_integer(date_time.month)
    year_bcd = from_integer(date_time.year - 2000)

    <<
      # unset the VL bit. The clock is guaranteed after this.
      0::integer-1,
      second_bcd::integer-7,
      # drop first bit
      0::integer-1,
      minute_bcd::integer-7,
      # drop first two bits
      0::integer-2,
      hour_bcd::integer-6,
      0::integer-2,
      day_bcd::integer-6,
      # drop first five bits
      0::integer-5,
      weekday_bcd::integer-3,
      # first bit is century. drop 2 bits.
      1::integer-1,
      0::integer-2,
      month_bcd::integer-5,
      year_bcd
    >>
  end

  defp registers_to_time(
         <<_vl::integer-1, second_bcd::integer-7, _::integer-1, minute_bcd::integer-7,
           _::integer-2, hour_bcd::integer-6, _::integer-2, day_bcd::integer-6, _::integer-5,
           _weekday_bcd::integer-3, _c::integer-1, _::integer-2, month_bcd::integer-5,
           year_bcd::integer-8>>
       ) do
    %NaiveDateTime{
      day: to_integer(day_bcd),
      hour: to_integer(hour_bcd),
      minute: to_integer(minute_bcd),
      month: to_integer(month_bcd),
      second: to_integer(second_bcd),
      year: 2000 + to_integer(year_bcd)
    }
  end
end