Integrating the Olight Ostation in Home Assistant [Guide]

[Guide] Integrating the Olight Ostation in Home Assistant

Hey everyone, I recently picked up the Olight Ostation 2 Smart 3-in-1 Battery Charger and wanted to get it fully integrated into Home Assistant. It’s a great piece of hardware! However, the stock Olight app isn’t my favorite, so getting this running locally via a Tuya Local Wi-Fi implementation was a priority.

Quick vs. Advanced Implementation

  • The Advanced Method: If you want voltage, current, and status, you need template sensors to decode the hex-encoded packets, and the UI below makes that look pretty.

Uploading: image.png…

  • The Super Quick Method: If you want a super quick implementation, just drop the device YAML into your tuya_local device folder. You don’t need the UI and template sensors below if you don’t care about more than on/off and basic stuff.


Prerequisites: What You Will Need

Before you begin, you will need to extract three specific pieces of information to talk to this device locally on your network:

  • DEVICE_ID: Your unique 22-character device ID.
  • IP_ADDRESS: The local IP address of your charger on your network (e.g., 192.168.1.X).
  • LOCAL_KEY: Your 16-character local encryption key.

Getting the Local Key

Connect your phone in USB debug mode. Running ADB, you can look at the logs and the Tuya SDK (labeled Thing-Network in your logs) has a debug leak where it prints the active localKey to the system log immediately before calculating the session key.

adb logcat -v time Thing-Network:D Thing-Bluetooth:D Thing-Activator:D *:S

Watch the logs to pull your 16-character localKey. Note: I’m pretty sure the BT and Wi-Fi local keys are different, but the Wi-Fi was the quickest way to get this working.


Step 1: Tuya Local Instructions

  1. Install Tuya Local: This is a Home Assistant integration via HACS to support devices running Tuya firmware without going via the Tuya cloud. Devices are supported over WiFi. Note that a reboot of Home Assistant is necessary after installing HACS components. Also, note that many Tuya devices seem to support only one local connection. I haven’t had any issue with Ostation integration running and my phone.
  2. Add the Custom Device Profile: Copy the following YAML and save it as /custom_components/tuya_local/devices/olight_ostation_charger.yaml. Restart HA.
Click to expand olight_ostation_charger.yaml
name: Charger
products:
  - id: 1fnensm6
    manufacturer: Olight
    model: Ostation
  - id: v8znljza
    manufacturer: Olight
    model: Ostation

entities:
  - entity: switch
    name: Power
    dps:
      - id: 101
        type: boolean
        name: switch

  - entity: sensor
    name: Fully charged count
    category: diagnostic
    dps:
      - id: 102
        type: integer
        name: sensor

  - entity: binary_sensor
    name: DP103
    category: diagnostic
    dps:
      - id: 103
        type: boolean
        name: sensor

  - entity: binary_sensor
    name: DP104
    category: diagnostic
    dps:
      - id: 104
        type: boolean
        name: sensor

  - entity: sensor
    name: DP105
    category: diagnostic
    dps:
      - id: 105
        type: integer
        name: sensor

  - entity: binary_sensor
    name: DP106
    category: diagnostic
    dps:
      - id: 106
        type: boolean
        name: sensor

  - entity: sensor
    name: Invalid battery count
    category: diagnostic
    dps:
      - id: 108
        type: integer
        name: sensor

  - entity: sensor
    name: Raw Bay Telemetry
    category: diagnostic
    dps:
      - id: 110
        type: string
        name: sensor
        optional: true
        
  - entity: switch
    name: Pause schedule
    dps:
      - id: 111
        type: boolean
        name: switch

  - entity: sensor
    name: Pause Window Raw
    category: diagnostic
    dps:
      - id: 112
        type: string
        name: sensor
        optional: true
        
  - entity: sensor
    name: DP113
    category: diagnostic
    dps:
      - id: 113
        type: integer
        name: sensor
  1. Configure the Integration: Go into Devices & Services on HA and MANUALLY add a new Tuya Local device.
    CRITICAL: Be absolutely sure to pick 3.5 interface for Tuya. Put in your local key that you got via ADB, your device ID, and IP. THEN you will be asked which device interface, and Charger (Ostation) will show up. Now you can simply add the result to your UI.

Step 2: Advanced Telemetry Decoding (Template Sensors)

The DP Slot Keys

For reference, here are the core DP (Data Point) keys the charger uses:

  • 101: Global Power State (Boolean)
  • 102: “Fully Charged” Counter (Matches the app GUI)
  • 108: “Invalid Batteries” Counter
  • 110: Live Multi-Bay Telemetry (The Hex Payload)
  • 111 / 112: Paused Charging Schedule Controls

The Hex Scheme (DP 110 Chunk Map)

When the Ostation sends DP 110, it sends a 68-byte hex string containing four 17-byte chunks. The slots are strictly ordered:

  • Bytes 0-16: Telemetry for Slot 3
  • Bytes 17-33: Telemetry for Slot 4
  • Bytes 34-50: Telemetry for Slot 1
  • Bytes 51-67: Telemetry for Slot 2

Slots 1 through 4 data are hex encoded packets. So if you want to decode all that, I included template sensors below that decode raw strings on packet 110 into charge type, voltage, etc. I don’t have lithium batteries or olight batteries to read likely other fields.
I’m not bothering to do anything with the on/off scheduler since you can do that control in HA anyway.

Within every 17-byte chunk, the bytes decode like this:

  • Byte 0: Slot ID (01, 02, 03, 04)
  • Byte 1: Chemistry (00 = Detecting, 01 = Ni-MH)
  • Byte 2: Battery % (00 - 64 Hex → 0% to 100%)
  • Byte 3: Error Flags
  • Byte 4: Charge cycle count (counts up charge cycles for ea battery)
  • Bytes 8-9: Voltage (Big-Endian Int, raw mV)
  • Bytes 10-11: Current (Big-Endian Int, requires 10x multiplier for mA)
  • Byte 12: Temperature (°C)

Add this to your configuration.yaml. This code will break the hex encoded data into the schema above.**

Click to expand Template Sensors
template:
  - sensor:

      #########################################################
      # SLOT 3 (bytes 0–16)
      #########################################################

      - name: Ostation Slot 3 ID
        unique_id: ostation_slot_3_id
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[0] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 3 Chemistry
        unique_id: ostation_slot_3_chemistry
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[1] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 3 Percent
        unique_id: ostation_slot_3_percent
        unit_of_measurement: "%"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[2] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 3 Flags
        unique_id: ostation_slot_3_flags
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[3] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 3 Charge State
        unique_id: ostation_slot_3_charge_state
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[4] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 3 Voltage
        unique_id: ostation_slot_3_voltage
        unit_of_measurement: "mV"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ (b[8] * 256) + b[9] }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 3 Current
        unique_id: ostation_slot_3_current
        unit_of_measurement: "mA"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ ((b[11] * 256) + b[10]) * 10 }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 3 Temperature
        unique_id: ostation_slot_3_temperature
        unit_of_measurement: "°C"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[12] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      #########################################################
      # SLOT 4 (bytes 17–33)
      #########################################################

      - name: Ostation Slot 4 ID
        unique_id: ostation_slot_4_id
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[17] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 4 Chemistry
        unique_id: ostation_slot_4_chemistry
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[18] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 4 Percent
        unique_id: ostation_slot_4_percent
        unit_of_measurement: "%"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[19] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 4 Flags
        unique_id: ostation_slot_4_flags
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[20] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 4 Charge State
        unique_id: ostation_slot_4_charge_state
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[21] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 4 Voltage
        unique_id: ostation_slot_4_voltage
        unit_of_measurement: "mV"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ (b[25] * 256) + b[26] }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 4 Current
        unique_id: ostation_slot_4_current
        unit_of_measurement: "mA"
        state: >
          {% set raw = 
---
### What the charger actually does (Observed Behavior)
Summary: The Ostation takes about 3 minutes of "probing" a battery (reading voltage and checking chemistry) before it officially flips into an active charging state and reports current/temp telemetry.   You can see the voltages it's reading even when it's not charging. 

Cells are physically moved in paired slots (AA and AAA separately). Within a pair, both cells must complete charging before the systestates('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ ((b[28] * 256) + b[27]) * 10 }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 4 Temperature
        unique_id: ostation_slot_4_temperature
        unit_of_measurement: "°C"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[29] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      #########################################################
      # SLOT 1 (bytes 34–50)
      #########################################################

      - name: Ostation Slot 1 ID
        unique_id: ostation_slot_1_id
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[34] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 1 Chemistry
        unique_id: ostation_slot_1_chemistry
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[35] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 1 Percent
        unique_id: ostation_slot_1_percent
        unit_of_measurement: "%"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[36] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 1 Flags
        unique_id: ostation_slot_1_flags
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[37] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 1 Charge State
        unique_id: ostation_slot_1_charge_state
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[38] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 1 Voltage
        unique_id: ostation_slot_1_voltage
        unit_of_measurement: "mV"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ (b[42] * 256) + b[43] }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 1 Current
        unique_id: ostation_slot_1_current
        unit_of_measurement: "mA"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ ((b[45] * 256) + b[44]) * 10 }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 1 Temperature
        unique_id: ostation_slot_1_temperature
        unit_of_measurement: "°C"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[46] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      #########################################################
      # SLOT 2 (bytes 51–67)
      #########################################################

      - name: Ostation Slot 2 ID
        unique_id: ostation_slot_2_id
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[51] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 2 Chemistry
        unique_id: ostation_slot_2_chemistry
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[52] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 2 Percent
        unique_id: ostation_slot_2_percent
        unit_of_measurement: "%"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[53] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 2 Flags
        unique_id: ostation_slot_2_flags
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[54] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 2 Charge State
        unique_id: ostation_slot_2_charge_state
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[55] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

      - name: Ostation Slot 2 Voltage
        unique_id: ostation_slot_2_voltage
        unit_of_measurement: "mV"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ (b[59] * 256) + b[60] }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 2 Current
        unique_id: ostation_slot_2_current
        unit_of_measurement: "mA"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}
              {{ ((b[62] * 256) + b[61]) * 10 }}
            {% else %}
              {{ none }}
            {% endif %}
          {% else %}
            {{ none }}
          {% endif %}

      - name: Ostation Slot 2 Temperature
        unique_id: ostation_slot_2_temperature
        unit_of_measurement: "°C"
        state: >
          {% set raw = states('sensor.ostation_charger_raw_bay_telemetry') %}
          {% if raw not in ['unknown','unavailable','',None] %}
            {% set b = raw | base64_decode(none) %}
            {% if b | length >= 68 %}{{ b[63] }}{% else %}{{ none }}{% endif %}
          {% else %}{{ none }}{% endif %}

CONTINUED

2 Likes

Step 3: The Lovelace UI

I never did find HA support for vertical progress bars like in the original olight olstation app. For progress bars I am using the Entity Progress Card (via HACS by KO4LA).

Note that when it says probing, it will show current/last voltage values, etc. So only when charging will it turn green.

Click to expand lovelace UI yaml
type: vertical-stack
cards:
  - square: false
    type: grid
    title: Ostation Battery Charger
    columns: 2
    cards:
      - type: vertical-stack
        cards:
          - type: custom:entity-progress-card-template
            entity: sensor.ostation_slot_1_percent
            icon: mdi:battery
            color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_1_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            bar_color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_1_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            name: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_1_chemistry') | int(0) %} {%
              if not pwr %} AAA1: Off {% elif ch > 0 %} AAA1: Charging {% else
              %} AAA1: Probing {% endif %}
            secondary: >-
              {% set ch = states('sensor.ostation_slot_1_chemistry') | int(0) %}
              {% set p = states('sensor.ostation_slot_1_percent') %} {% if ch >
              0 %} {{ p | int }}% {% else %} -- {% endif %}
            percent: >-
              {% set ch = states('sensor.ostation_slot_1_chemistry') | int(0) %}
              {{ states('sensor.ostation_slot_1_percent') | float(0) if ch > 0
              else 0 }}
            card_mod:
              style: |
                ha-card { margin-bottom: 0 !important; }
          - type: markdown
            content: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set bits = namespace(items=[]) %} {% if pwr %}
                {% set v = states('sensor.ostation_slot_1_voltage') | float(0) %}
                {% set c = states('sensor.ostation_slot_1_current') | float(0) %}
                {% set t = states('sensor.ostation_slot_1_temperature') | int(0) %}
                {% set ch = states('sensor.ostation_slot_1_chemistry') | int(0) %}
                {% if v > 0 %}{% set bits.items = bits.items + ['%.3f V'|format(v/1000)] %}{% endif %}
                {% if c > 0 %}{% set bits.items = bits.items + [c|int ~ ' mA'] %}{% endif %}
                {% if ch > 0 %}
                  {% if t > 0 and t < 90 %}{% set bits.items = bits.items + [t ~ '°C'] %}{% endif %}
                  {% if ch == 1 %}{% set bits.items = bits.items + ['Ni-MH'] %}
                  {% elif ch == 2 %}{% set bits.items = bits.items + ['Li'] %}{% endif %}
                {% endif %}
              {% endif %} <sub style="color: var(--secondary-text-color);">
                {{ bits.items | join(' | ') if bits.items | length > 0 else '&nbsp;' }}
              </sub>
            card_mod:
              style: |
                ha-card { 
                  box-shadow: none !important; border: 0 !important;
                  background: transparent !important; margin-top: -8px !important;
                  padding-top: 0 !important; 
                } 
                ha-markdown { 
                  padding: 0 12px 8px 56px !important; 
                  line-height: 1.2 !important; 
                }
      - type: vertical-stack
        cards:
          - type: custom:entity-progress-card-template
            entity: sensor.ostation_slot_2_percent
            icon: mdi:battery
            color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_2_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            bar_color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_2_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            name: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_2_chemistry') | int(0) %} {%
              if not pwr %} AAA2: Off {% elif ch > 0 %} AAA2: Charging {% else
              %} AAA2: Probing {% endif %}
            secondary: >-
              {% set ch = states('sensor.ostation_slot_2_chemistry') | int(0) %}
              {% set p = states('sensor.ostation_slot_2_percent') %} {% if ch >
              0 %} {{ p | int }}% {% else %} -- {% endif %}
            percent: >-
              {% set ch = states('sensor.ostation_slot_2_chemistry') | int(0) %}
              {{ states('sensor.ostation_slot_2_percent') | float(0) if ch > 0
              else 0 }}
            card_mod:
              style: |
                ha-card { margin-bottom: 0 !important; }
          - type: markdown
            content: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set bits = namespace(items=[]) %} {% if pwr %}
                {% set v = states('sensor.ostation_slot_2_voltage') | float(0) %}
                {% set c = states('sensor.ostation_slot_2_current') | float(0) %}
                {% set t = states('sensor.ostation_slot_2_temperature') | int(0) %}
                {% set ch = states('sensor.ostation_slot_2_chemistry') | int(0) %}
                {% if v > 0 %}{% set bits.items = bits.items + ['%.3f V'|format(v/1000)] %}{% endif %}
                {% if c > 0 %}{% set bits.items = bits.items + [c|int ~ ' mA'] %}{% endif %}
                {% if ch > 0 %}
                  {% if t > 0 and t < 90 %}{% set bits.items = bits.items + [t ~ '°C'] %}{% endif %}
                  {% if ch == 1 %}{% set bits.items = bits.items + ['Ni-MH'] %}
                  {% elif ch == 2 %}{% set bits.items = bits.items + ['Li'] %}{% endif %}
                {% endif %}
              {% endif %} <sub style="color: var(--secondary-text-color);">
                {{ bits.items | join(' | ') if bits.items | length > 0 else '&nbsp;' }}
              </sub>
            card_mod:
              style: |
                ha-card { 
                  box-shadow: none !important; border: 0 !important;
                  background: transparent !important; margin-top: -8px !important;
                  padding-top: 0 !important; 
                } 
                ha-markdown { 
                  padding: 0 12px 8px 56px !important; 
                  line-height: 1.2 !important; 
                }
      - type: vertical-stack
        cards:
          - type: custom:entity-progress-card-template
            entity: sensor.ostation_slot_3_percent
            icon: mdi:battery
            color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_3_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            bar_color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_3_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            name: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_3_chemistry') | int(0) %} {%
              if not pwr %} AA1: Off {% elif ch > 0 %} AA1: Charging {% else %}
              AA1: Probing {% endif %}
            secondary: >-
              {% set ch = states('sensor.ostation_slot_3_chemistry') | int(0) %}
              {% set p = states('sensor.ostation_slot_3_percent') %} {% if ch >
              0 %} {{ p | int }}% {% else %} -- {% endif %}
            percent: >-
              {% set ch = states('sensor.ostation_slot_3_chemistry') | int(0) %}
              {{ states('sensor.ostation_slot_3_percent') | float(0) if ch > 0
              else 0 }}
            card_mod:
              style: |
                ha-card { margin-bottom: 0 !important; }
          - type: markdown
            content: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set bits = namespace(items=[]) %} {% if pwr %}
                {% set v = states('sensor.ostation_slot_3_voltage') | float(0) %}
                {% set c = states('sensor.ostation_slot_3_current') | float(0) %}
                {% set t = states('sensor.ostation_slot_3_temperature') | int(0) %}
                {% set ch = states('sensor.ostation_slot_3_chemistry') | int(0) %}
                {% if v > 0 %}{% set bits.items = bits.items + ['%.3f V'|format(v/1000)] %}{% endif %}
                {% if c > 0 %}{% set bits.items = bits.items + [c|int ~ ' mA'] %}{% endif %}
                {% if ch > 0 %}
                  {% if t > 0 and t < 80 %}{% set bits.items = bits.items + [t ~ '°C'] %}{% endif %}
                  {% if ch == 1 %}{% set bits.items = bits.items + ['Ni-MH'] %}
                  {% elif ch == 2 %}{% set bits.items = bits.items + ['Li'] %}{% endif %}
                {% endif %}
              {% endif %} <sub style="color: var(--secondary-text-color);">
                {{ bits.items | join(' | ') if bits.items | length > 0 else '&nbsp;' }}
              </sub>
            card_mod:
              style: |
                ha-card { 
                  box-shadow: none !important; border: 0 !important;
                  background: transparent !important; margin-top: -8px !important;
                  padding-top: 0 !important; 
                } 
                ha-markdown { 
                  padding: 0 12px 8px 56px !important; 
                  line-height: 1.2 !important; 
                }
      - type: vertical-stack
        cards:
          - type: custom:entity-progress-card-template
            entity: sensor.ostation_slot_4_percent
            icon: mdi:battery
            color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_4_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            bar_color: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_4_chemistry') | int(0) %} {%
              if pwr and ch > 0 %} green {% else %} grey {% endif %}
            name: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set ch = states('sensor.ostation_slot_4_chemistry') | int(0) %} {%
              if not pwr %} AA2: Off {% elif ch > 0 %} AA2: Charging {% else %}
              AA2: Probing {% endif %}
            secondary: >-
              {% set ch = states('sensor.ostation_slot_4_chemistry') | int(0) %}
              {% set p = states('sensor.ostation_slot_4_percent') %} {% if ch >
              0 %} {{ p | int }}% {% else %} -- {% endif %}
            percent: >-
              {% set ch = states('sensor.ostation_slot_4_chemistry') | int(0) %}
              {{ states('sensor.ostation_slot_4_percent') | float(0) if ch > 0
              else 0 }}
            card_mod:
              style: |
                ha-card { margin-bottom: 0 !important; }
          - type: markdown
            content: >-
              {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {%
              set bits = namespace(items=[]) %} {% if pwr %}
                {% set v = states('sensor.ostation_slot_4_voltage') | float(0) %}
                {% set c = states('sensor.ostation_slot_4_current') | float(0) %}
                {% set t = states('sensor.ostation_slot_4_temperature') | int(0) %}
                {% set ch = states('sensor.ostation_slot_4_chemistry') | int(0) %}
                {% if v > 0 %}{% set bits.items = bits.items + ['%.3f V'|format(v/1000)] %}{% endif %}
                {% if c > 0 %}{% set bits.items = bits.items + [c|int ~ ' mA'] %}{% endif %}
                {% if ch > 0 %}
                  {% if t > 0 and t < 80 %}{% set bits.items = bits.items + [t ~ '°C'] %}{% endif %}
                  {% if ch == 1 %}{% set bits.items = bits.items + ['Ni-MH'] %}
                  {% elif ch == 2 %}{% set bits.items = bits.items + ['Li'] %}{% endif %}
                {% endif %}
              {% endif %} <sub style="color: var(--secondary-text-color);">
                {{ bits.items | join(' | ') if bits.items | length > 0 else '&nbsp;' }}
              </sub>
            card_mod:
              style: |
                ha-card { 
                  box-shadow: none !important; border: 0 !important;
                  background: transparent !important; margin-top: -8px !important;
                  padding-top: 0 !important; 
                } 
                ha-markdown { 
                  padding: 0 12px 8px 56px !important; 
                  line-height: 1.2 !important; 
                }
  - type: entities
    show_header_toggle: false
    entities:
      - entity: switch.ostation_charger_power
        name: Power Control
      - entity: sensor.ostation_charger_fully_charged_count
        name: Total Charged
      - entity: sensor.ostation_charger_invalid_battery_count
        name: Total Rejected

What the charger actually does (Observed Behavior)

Summary: The Ostation takes about 3 minutes of “probing” a battery (reading voltage and checking chemistry) before it officially flips into an active charging state and reports current/temp telemetry. You can see the voltages it’s reading even when it’s not charging.

Cells are processed in paired slots (AA and AAA separately). Within a pair, both cells must complete charging before the system advances the queue. This means that even if another battery is waiting, it won’t begin charging until both slots in the active pair are finished. The AA and AAA channels operate independently of each other.

At the firmware level, the Ostation’s Ni-MH charging process is driven by 17-byte telemetry records (Tuya DP110) that reveal a rigorous pre-charge handshake. Before committing to a battery chemistry, the charger performs exactly three iterative load-test “heartbeats” (Probing). Telemetry reveals this as a rhythmic signature where the Battery % alternates between a provisional load-sag value and zero across sequential packets while the device measures internal resistance (ESR) and open-circuit voltage recovery. This rhythmic pulse is the primary indicator of hardware qualification; any cell that fails this handshake—identifiable when the global DP108 Invalid Battery counter increments while a slot reports active current but $0.000\text{V}$ (the Pulse Fail signature)—is immediately rejected.

Once the handshake is successful, the firmware “Commits” (Chemistry Byte flips from 00 to 01) and transitions into true charging, incrementing a cycle counter (Byte 4) that represents internal saturation checks. During the high-load Probing and initial Commit phases, electrical noise frequently causes temperature spikes, which stabilize into real-world readings once a steady-state charge is established. Completion is officially marked by the global DP102 counter incrementing as the specific slot’s Chemistry return to 00. Crucially, the firmware is “lazy” about clearing per-slot registers; stale voltage and percentage values often persist in the buffer as “ghosts” after a battery is removed or a charge finishes. Because of this, the only truly empty state is a confirmed 0.000V reading. For reliable Home Assistant logic, any telemetry where Voltage is present but Chemistry is 00 must be treated as an untrusted Probing or Ghost state until the Chemistry Gate is passed.

Note: I’m not total confident the temp value is truly a celcius measurement
Occasional values (e.g., ~100 °C) have been observed, and I’m guessing at the explanations above—use this data cautiously.

GENERAL INFO & Architecture (For Posterity)

Olight does not build their own app infrastructure. The “Olight omall” app is an OEM White-Label App generated automatically by the Tuya Smart Life App SDK (recently rebranded by Tuya as the ThingSmart SDK). This is a Thingclips/Tuya BizBundle app. Olight is built on the Thingclips/Tuya mobile stack, not a one-off proprietary app.

  • The app uses the standard Thingclips device model, DP schema system, and publishDps(...) control path. The app converts human-readable DP codes into device DP IDs before sending commands.
  • We know that the Olink device control does NOT require cloud, and the BLE path is first-class, not fallback.
  • BLE and Wi-Fi Share Same DP Pipeline. Transport changes, semantics don’t.
  • Encryption Is Deterministic (No Random IV Generation). IV = secKey[:16].

Tuya BLE, Mini-Apps, and Cloud Roadblocks:
I think it is possible to get Tuya BLE to work. Once HA Tuya BLE gets their 3.5 handshakes working, it could work over BT as well. I started down that path, but Tuya BLE implementations aren’t as mature.

  • You likely cannot use the cloud implementation since Olight is using their own servers and thus your device won’t be associated with your Tuya account. I was also unable to get the device to pair with the standard Tuya app.
  • When you pair the Ostation to your phone, the app silently downloads a “Panel Bundle” (a compressed package of JavaScript files specific to the Ostation) directly from Olight’s cloud. Tuya realized that packing the UI for thousands of different smart devices into one APK would make the app 5GB in size. Instead, they use a dynamic framework called Tuya Ray (formerly React Native Panels).
  • I tried decompiling the APK on my Linux machine to get at this, but you can’t get the mini app without a rooted Android or an emulator. Even when you do use a rooted phone, there are lots of protections around the Tuya app. To get the Olink app to actually run on my rooted phone without crashing, I had to use F-Droid to install Magisk, enable Zygisk, and install microG. From there, I used Enforce DenyList in Magisk and checked all the boxes (using Shamiko is also a highly recommended path for bypassing Tuya’s modern Play Integrity checks). Only then was I able to use ADB to download the entire mini-app sandbox that is fenced off on non-rooted phones. However, even when you get the panel off the device, it’s not source code and, as near as I can tell, it’s just stored in a cache.

Bonus: Local LAN Packet Sniffing (Python / TinyTuya)

If you want to debug the live packets (without using Home Assistant) pushing from the charger, verify your keys are correct, or map out extra datapoints yourself, here is a quick Python script utilizing the tinytuya library.

Important Notes for this to work:

  • set_version(3.5) absolutely matters—if you use the wrong version you will just get garbage decoding or no decode at all.
  • receive() is the key method—this listens for device-pushed updates rather than constantly polling.
  • heartbeat() must be sent, otherwise the Ostation will kill the socket connection.
Click to expand Python Script
import tinytuya
import json
import time

# Credentials
DEVICE_ID = '[YOUR_22_CHAR_DEVICE_ID]'
IP_ADDRESS = '[YOUR_CHARGER_IP]'
LOCAL_KEY = '[YOUR_16_CHAR_LOCAL_KEY]'

# Use the base Device class
d = tinytuya.Device(DEVICE_ID, IP_ADDRESS, LOCAL_KEY)
d.set_version(3.5)
d.set_socketPersistent(True) # Keep the pipe open

print(f"--- OPENING RAW SOCKET TO {IP_ADDRESS} ---")
print("I am dumping every byte the charger sends. Move a battery to trigger a report.")

while True:
    try:
        # receive() catches unsolicited broadcasts
        data = d.receive()
        
        if data:
            print(f"\n[{time.strftime('%H:%M:%S')}] RAW PACKET:")
            print(json.dumps(data, indent=2))
            
            if 'dps' in data:
                for dp, val in data['dps'].items():
                    if val in [100, 96, 91]:
                        print(f"  [!!!] FOUND TELEMETRY: DP {dp} = {val}%")
        
        # We must send heartbeats or the Ostation will kill the connection
        d.heartbeat()
        time.sleep(2)
        
    except KeyboardInterrupt:
        break
    except Exception as e:
        if "timeout" not in str(e).lower():
            print(f"Socket error: {e}")

Bonus: Ostation Charge History Log

If you want a logging window that tracks the last 100 charging events, here is how I did it. It’s a little challenging to track which slots it is rejecting from vice being pretty clear when charging.

I used a Local To-do list as the log backend because the built-in logbook adds a lot of extra detail and is time-based. This keeps the history compact and dashboard-friendly.

Create a Local To-do list named:

Ostation History

That should create this entity:

todo.ostation_history

Add this card to the Lovelace UI above.

- type: todo-list
  entity: todo.ostation_history
  title: Ostation History
  hide_create: true
  hide_completed: true
  hide_section_headers: true
  display_order: none

Add this to automations.yaml.

# ----------------------------------------------------------------
# OLIGHT OSTATION CHARGE HISTORY 🔋
#
# Uses Local To-do list:
#   todo.ostation_history
#
# OUTPUT FORMAT EXAMPLES:
#   04/25 12:35 AM • AAA1 • 1.455→1.458V • 0m
#   04/25 12:36 AM • AAA2 Rejected
#
# Slot labels:
#   Slot 1 = AAA1
#   Slot 2 = AAA2
#   Slot 3 = AA1
#   Slot 4 = AA2
#
# Keeps only the newest 100 open To-do items.
# ----------------------------------------------------------------

- id: ostation_charge_started
  alias: "Ostation - Charge Started"
  mode: parallel
  trigger:
    - platform: state
      entity_id:
        - sensor.ostation_slot_1_chemistry
        - sensor.ostation_slot_2_chemistry
        - sensor.ostation_slot_3_chemistry
        - sensor.ostation_slot_4_chemistry
      from: "0" # The "Commit" Gate: Only trigger when a battery type is locked in
  action:
    - variables:
        slot: "{{ trigger.entity_id.split('_slot_')[1].split('_')[0] | trim }}"

    - action: input_datetime.set_datetime
      target:
        entity_id: "input_datetime.ostation_slot_{{ slot }}_started_at"
      data:
        datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"

    - action: input_number.set_value
      target:
        entity_id: "input_number.ostation_slot_{{ slot }}_volt_start"
      data:
        value: "{{ states('sensor.ostation_slot_' ~ slot ~ '_voltage') | float(0) }}"

- id: ostation_charge_completed_log
  alias: "Ostation - Charge Completed Log"
  mode: parallel
  trigger:
    - platform: state
      entity_id:
        - sensor.ostation_slot_1_chemistry
        - sensor.ostation_slot_2_chemistry
        - sensor.ostation_slot_3_chemistry
        - sensor.ostation_slot_4_chemistry
      to: "0" # Physical proof the charger closed the session for this bay
  condition:
    # Only log if the power is ON, which prevents logs when you flip the global power switch,
    # and only if the bay was actually just in a charging state.
    - condition: template
      value_template: >
        {{ is_state('switch.ostation_charger_power', 'on') and
           trigger.from_state.state | int(0) > 0 }}
  action:
    - variables:
        slot: "{{ trigger.entity_id.split('_slot_')[1].split('_')[0] | trim }}"
        label: "{% if slot in ['1','2'] %}AAA{% else %}AA{% endif %}{% if slot in ['1','3'] %}1{% else %}2{% endif %}"
        v_start: "{{ states('input_number.ostation_slot_' ~ slot ~ '_volt_start') | float(0) }}"
        # In the packet where Chemistry hits 0, the voltage is the final charge voltage.
        v_end: "{{ states('sensor.ostation_slot_' ~ slot ~ '_voltage') | float(0) }}"
        dur: >
          {% set start = states('input_datetime.ostation_slot_' ~ slot ~ '_started_at') %}
          {{ ((as_timestamp(now()) - as_timestamp(start)) / 60) | int(0)
             if start not in ['unknown','unavailable',''] else 0 }}

    - action: todo.add_item
      target:
        entity_id: todo.ostation_history
      data:
        item: >
          {{ now().strftime('%m/%d %I:%M %p') }} • {{ label }} •
          {{ '%.3f' | format(v_start / 1000) }}→{{ '%.3f' | format(v_end / 1000) }}V • {{ dur }}m

    # Prune the list back to the newest 100 open items.
    - action: todo.get_items
      target:
        entity_id: todo.ostation_history
      data:
        status:
          - needs_action
      response_variable: ostation_history_items

    - repeat:
        for_each: >
          {{ ostation_history_items['todo.ostation_history']['items'][:-100]
             | map(attribute='uid') | list }}
        sequence:
          - action: todo.remove_item
            target:
              entity_id: todo.ostation_history
            data:
              item: "{{ repeat.item }}"

- id: ostation_battery_rejected_log
  alias: "Ostation - Log Rejected Battery"
  mode: parallel
  trigger:
    - platform: state
      entity_id: sensor.ostation_charger_invalid_battery_count
  condition:
    - condition: template
      value_template: >
        {{ trigger.from_state.state not in ['unknown','unavailable',''] and
           trigger.to_state.state | int(0) > trigger.from_state.state | int(0) }}
  action:
    # Find the slot that updated its voltage most recently within 10 seconds
    # and currently reports 0V, which is the rejection signature.
    - variables:
        bad_slot: >
          {% set ns = namespace(found='1') %}
          {% for i in ['1','2','3','4'] %}
            {% set entity = states['sensor.ostation_slot_' ~ i ~ '_voltage'] %}
            {% set last_upd = as_timestamp(entity.last_changed) %}
            {% if (as_timestamp(now()) - last_upd) < 10 and entity.state | float(0) == 0 %}
              {% set ns.found = i %}
            {% endif %}
          {% endfor %}
          {{ ns.found }}

    - variables:
        label: "{% if bad_slot in ['1','2'] %}AAA{% else %}AA{% endif %}{% if bad_slot in ['1','3'] %}1{% else %}2{% endif %}"

    - action: todo.add_item
      target:
        entity_id: todo.ostation_history
      data:
        item: "{{ now().strftime('%m/%d %I:%M %p') }} • {{ label }} Rejected"

    # Prune the list back to the newest 100 open items.
    - action: todo.get_items
      target:
        entity_id: todo.ostation_history
      data:
        status:
          - needs_action
      response_variable: ostation_history_items

    - repeat:
        for_each: >
          {{ ostation_history_items['todo.ostation_history']['items'][:-100]
             | map(attribute='uid') | list }}
        sequence:
          - action: todo.remove_item
            target:
              entity_id: todo.ostation_history
            data:
              item: "{{ repeat.item }}"

You will also need these helpers. You can add them in the GUI by hand under Settings → Devices & services → Helpers, or add them to configuration.yaml.

input_datetime:
  ostation_slot_1_started_at:
    name: Ostation Slot 1 Started At
    has_date: true
    has_time: true

  ostation_slot_2_started_at:
    name: Ostation Slot 2 Started At
    has_date: true
    has_time: true

  ostation_slot_3_started_at:
    name: Ostation Slot 3 Started At
    has_date: true
    has_time: true

  ostation_slot_4_started_at:
    name: Ostation Slot 4 Started At
    has_date: true
    has_time: true

input_number:
  ostation_slot_1_volt_start:
    name: Ostation Slot 1 Start Voltage
    min: 0
    max: 5000
    step: 1

  ostation_slot_2_volt_start:
    name: Ostation Slot 2 Start Voltage
    min: 0
    max: 5000
    step: 1

  ostation_slot_3_volt_start:
    name: Ostation Slot 3 Start Voltage
    min: 0
    max: 5000
    step: 1

  ostation_slot_4_volt_start:
    name: Ostation Slot 4 Start Voltage
    min: 0
    max: 5000
    step: 1

Mini GUI / Home Screen Card

I also made a compact mini GUI for the Ostation.

Using this code, it shows each slot’s charging status at a glance, including the charge percent and voltage. The battery icons act like tiny vertical bar graphs, so you can quickly tell which batteries are charging and roughly how full they are without opening the full charger page.

I use this on my main Home dashboard. Each battery slot is tappable and opens the more detailed Ostation dashboard.

Click to expand Ostation Mini GUI YAML

square: false
type: grid
columns: 5
cards:
  - type: markdown
    content: >-
      {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {% set p =
      states('sensor.ostation_slot_1_percent') | int(0) %} {% set ch =
      states('sensor.ostation_slot_1_chemistry') | int(0) %} {% set v =
      states('sensor.ostation_slot_1_voltage') | float(0) %} {% set color =
      '#4caf50' if (pwr and ch > 0) else '#888888' %} {% if not pwr or ch <= 0 %}
        {% set icon = 'mdi:battery' %}
      {% elif p >= 95 %}
        {% set icon = 'mdi:battery' %}
      {% elif p <= 5 %}
        {% set icon = 'mdi:battery-outline' %}
      {% else %}
        {% set level = ((p + 5) // 10) * 10 %}
        {% if level > 90 %}{% set level = 90 %}{% endif %}
        {% if level < 10 %}{% set level = 10 %}{% endif %}
        {% set icon = 'mdi:battery-' ~ level %}
      {% endif %} <div
      style="text-align:center; white-space:nowrap;">
        <font color="{{ color }}"><ha-icon icon="{{ icon }}" style="--mdc-icon-size:10px;"></ha-icon></font><br>
        <small>{% if ch > 0 and pwr %}{{ p }}% {% endif %}AAA1</small><br>
        <small><small>{% if v > 0 and pwr %}{{ '%.3fV' | format(v / 1000) }}{% else %}&nbsp;{% endif %}</small></small>
      </div>
    tap_action:
      action: navigate
      navigation_path: /lovelace/ostation

  - type: markdown
    content: >-
      {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {% set p =
      states('sensor.ostation_slot_2_percent') | int(0) %} {% set ch =
      states('sensor.ostation_slot_2_chemistry') | int(0) %} {% set v =
      states('sensor.ostation_slot_2_voltage') | float(0) %} {% set color =
      '#4caf50' if (pwr and ch > 0) else '#888888' %} {% if not pwr or ch <= 0 %}
        {% set icon = 'mdi:battery' %}
      {% elif p >= 95 %}
        {% set icon = 'mdi:battery' %}
      {% elif p <= 5 %}
        {% set icon = 'mdi:battery-outline' %}
      {% else %}
        {% set level = ((p + 5) // 10) * 10 %}
        {% if level > 90 %}{% set level = 90 %}{% endif %}
        {% if level < 10 %}{% set level = 10 %}{% endif %}
        {% set icon = 'mdi:battery-' ~ level %}
      {% endif %} <div
      style="text-align:center; white-space:nowrap;">
        <font color="{{ color }}"><ha-icon icon="{{ icon }}" style="--mdc-icon-size:10px;"></ha-icon></font><br>
        <small>{% if ch > 0 and pwr %}{{ p }}% {% endif %}AAA2</small><br>
        <small><small>{% if v > 0 and pwr %}{{ '%.3fV' | format(v / 1000) }}{% else %}&nbsp;{% endif %}</small></small>
      </div>
    tap_action:
      action: navigate
      navigation_path: /lovelace/ostation

  - type: markdown
    content: >-
      {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {% set p =
      states('sensor.ostation_slot_3_percent') | int(0) %} {% set ch =
      states('sensor.ostation_slot_3_chemistry') | int(0) %} {% set v =
      states('sensor.ostation_slot_3_voltage') | float(0) %} {% set color =
      '#4caf50' if (pwr and ch > 0) else '#888888' %} {% if not pwr or ch <= 0 %}
        {% set icon = 'mdi:battery' %}
      {% elif p >= 95 %}
        {% set icon = 'mdi:battery' %}
      {% elif p <= 5 %}
        {% set icon = 'mdi:battery-outline' %}
      {% else %}
        {% set level = ((p + 5) // 10) * 10 %}
        {% if level > 90 %}{% set level = 90 %}{% endif %}
        {% if level < 10 %}{% set level = 10 %}{% endif %}
        {% set icon = 'mdi:battery-' ~ level %}
      {% endif %} <div
      style="text-align:center; white-space:nowrap;">
        <font color="{{ color }}"><ha-icon icon="{{ icon }}" style="--mdc-icon-size:10px;"></ha-icon></font><br>
        <small>{% if ch > 0 and pwr %}{{ p }}% {% endif %}AA1</small><br>
        <small><small>{% if v > 0 and pwr %}{{ '%.3fV' | format(v / 1000) }}{% else %}&nbsp;{% endif %}</small></small>
      </div>
    tap_action:
      action: navigate
      navigation_path: /lovelace/ostation

  - type: markdown
    content: >-
      {% set pwr = is_state('switch.ostation_charger_power', 'on') %} {% set p =
      states('sensor.ostation_slot_4_percent') | int(0) %} {% set ch =
      states('sensor.ostation_slot_4_chemistry') | int(0) %} {% set v =
      states('sensor.ostation_slot_4_voltage') | float(0) %} {% set color =
      '#4caf50' if (pwr and ch > 0) else '#888888' %} {% if not pwr or ch <= 0 %}
        {% set icon = 'mdi:battery' %}
      {% elif p >= 95 %}
        {% set icon = 'mdi:battery' %}
      {% elif p <= 5 %}
        {% set icon = 'mdi:battery-outline' %}
      {% else %}
        {% set level = ((p + 5) // 10) * 10 %}
        {% if level > 90 %}{% set level = 90 %}{% endif %}
        {% if level < 10 %}{% set level = 10 %}{% endif %}
        {% set icon = 'mdi:battery-' ~ level %}
      {% endif %} <div
      style="text-align:center; white-space:nowrap;">
        <font color="{{ color }}"><ha-icon icon="{{ icon }}" style="--mdc-icon-size:10px;"></ha-icon></font><br>
        <small>{% if ch > 0 and pwr %}{{ p }}% {% endif %}AA2</small><br>
        <small><small>{% if v > 0 and pwr %}{{ '%.3fV' | format(v / 1000) }}{% else %}&nbsp;{% endif %}</small></small>
      </div>
    tap_action:
      action: navigate
      navigation_path: /lovelace/ostation

  - type: button
    entity: switch.ostation_charger_power
    name: Power
    show_name: true
    show_state: true
    tap_action:
      action: toggle

title: Ostation Battery Charger