Installing Proxmox Backup Server on Hyper-V with NAS-Based CIFS Storage

This guide was created using the following versions:

  • Proxmox Virtual Environment: 9.1.1
  • Proxmox Backup Server: 4.1.5

As a first step, you will need a Debian 13 ISO image, which can be downloaded here: Debian — Downloading Debian

On your Hyper-V host, create a new virtual machine with the following configuration:

  • Name: PBS
  • Generation: Generation 1
  • Memory: 2048 MB RAM (dynamic memory is optional, but not required)
  • Network: Your preferred network connection
  • Virtual hard disk: 32 GB
    • Installation options: Install an operating system from a bootable CD/DVD-ROM
  • Image file (.iso): Select the previously downloaded debian13.iso
  • Once the configuration is complete, click Finish — but do not start the VM yet.

Open the VM settings and adjust the following options:

  • Processors: 2 processors are sufficient
  • Network Adapter: If required, configure the appropriate VLAN ID
  • Integration Services: Enable Guest Services
  • Checkpoints: Disable checkpoints if they are not needed
  • Automatic Start Action: Set this to Automatically start if it was running when the service stopped
  • Automatic Stop Action: Set this to Shut down the guest operating system

Start the virtual machine and begin the installation using the non-graphical installer.

Select the desired language, country, and keyboard layout.

When prompted, set the hostname to match the VM name, for example: pbs.

If you do not have a domain name, leave the Domain name field blank.

Set a root password.

Enter a full name for the user account. You can use something like sysop or any other name you prefer.

Then define the username, for example sysop.

Set the password for the sysop user.

For disk partitioning, choose:

  • Guided – use entire disk
  • Select the target disk
  • Choose All files in one partition (recommended for new users)

Then:

  • Finish partitioning and confirm the changes
  • When asked whether the changes should be written to disk, select Yes
    • Since the VHDX was just created, it should not contain any data yet

The operating system installation will now begin.

During the remaining setup:

  • If prompted for an additional installation medium, select No
  • Choose your country for the package mirror and then select the mirror server
    • In most cases, the default option is fine
  • If your environment requires a proxy for internet access, enter it in the next step
    • Otherwise, leave the field blank
  • When asked about popularity-contest, choose Yes only if you want to participate

For software selection:

Only select SSH server and standard system utilities

A Debian desktop environment such as GNOME is not required

Since this system will only run a single operating system, you can safely install GRUB on the primary disk.

Select the corresponding disk for the GRUB boot loader installation.

Click Continue to complete the installation. The VM will then reboot automatically.

After the reboot, log in as sysop and run ip addr to display the IP address assigned by DHCP.

ip addr

You can now connect to the VM via SSH using the IP address displayed earlier.

As a next step, configure a static IP address for the Proxmox Backup Server.
Please note that DHCP will no longer be used after the Proxmox Backup Server installation, so the system should be configured with a static IP address beforehand.

Then switch to administrative mode.

su

and enter your root password.

nano /etc/network/interfaces

At this point, your eth0 interface is still configured to use DHCP.

Adjust the network configuration to match your environment, for example:

# The primary network interface
allow-hotplug eth0
iface eth0 inet static
    address 10.4.4.20
    netmask 255.255.255.0
    gateway 10.4.4.1
    dns-nameservers 10.4.4.1

To save the file, press Ctrl + X, then Y, and finally Enter.Code-Sprache: CSS (css)

Now restart the network services.

If you cannot reconnect via SSH using the newly configured IP address, perform a reboot from the Hyper-V console and verify the network settings there.

systemctl restart networking

NAS

In the next step, create a CIFS/SMB share on your NAS and configure a user account with read and write permissions for that share.

iSCSI would generally be the preferred option. However, in my case, the NAS does not have a free storage pool available for an iSCSI LUN, so this setup uses CIFS/SMB instead.

Example share path:

\\10.4.5.200\backup\pbs

PBS

Connect to the system again via SSH and switch with su to administrative mode.

Then install the required CIFS packages.

apt update
apt install -y cifs-utils

Create the mount point for the CIFS share.

mkdir -p /mnt/pbs-nas

Create a credentials file to store the CIFS/SMB username and password.

nano /root/.smb-pbs

Use the following content:

username=NASSHAREUSER
password=YOURNASSHAREPASSWORD
chmod 600 /root/.smb-pbs

Add the share to /etc/fstab to configure a persistent mount that will be restored automatically after each reboot.

nano /etc/fstab
//10.4.5.200/backup/pbs /mnt/pbs-nas cifs credentials=/root/.smb-pbs,vers=3.0,uid=34,noforceuid,gid=34,noforcegid,iocharset=utf8,file_mode=0660,dir_mode=0770,_netdev,x-systemd.automount,nofail 0 0Code-Sprache: JSON / JSON mit Kommentaren (json)

Test the configuration to ensure the share mounts correctly.

mount -a
ls -la /mnt/pbs-nas

Install Proxmox Backup Server.

mkdir -p /usr/share/keyrings

wget https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg -O /usr/share/keyrings/proxmox-archive-keyring.gpgCode-Sprache: JavaScript (javascript)
  • (without subscription)
cat > /etc/apt/sources.list.d/proxmox.sources << 'EOF'
Types: deb
URIs: http://download.proxmox.com/debian/pbs
Suites: trixie
Components: pbs-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOFCode-Sprache: JavaScript (javascript)
  • (with subscription)
cat > /etc/apt/sources.list.d/pbs-enterprise.sources << 'EOF'
Types: deb
URIs: https://enterprise.proxmox.com/debian/pbs
Suites: trixie
Components: pbs-enterprise
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOFCode-Sprache: JavaScript (javascript)
apt update
apt install proxmox-backup-server
apt install proxmox-backup-client

Now open your browser and connect to your Proxmox Backup Server.

https://DEINE-DEBIAN-IP:8007Code-Sprache: JavaScript (javascript)

Username: root
Password: Use the same password you have been working with so far

The first thing we need to create is a Datastore, which will be located on the mounted CIFS share.

Go to Datastore and click Add Datastore, then use the following settings:

Backing Path: /mnt/pbs-nas

Name: nas-smb-store

Datastore Type: Local

The datastore chunks will now be created.

It should now look like this:

When you back up a container or virtual machine, the backup data is stored using the corresponding VM or CT ID.

If you operate multiple Proxmox VE servers outside of a cluster, it is strongly recommended to work with namespaces in order to keep backups clearly separated.

In fact, creating a namespace is a good practice even if you currently have only one Proxmox VE server, as it keeps the datastore structure clean and makes future expansion easier.

When creating namespaces, use the name of your Proxmox VE host, not the name of the Proxmox Backup Server.

Example namespaces:

Server02

Server01

proxmox-backup-client namespace create Server01 --repository root@pam@localhost:nas-smb-store
proxmox-backup-client namespace create Server02 --repository root@pam@localhost:nas-smb-storeCode-Sprache: PHP (php)

Next, you can connect your Proxmox VE server to the Proxmox Backup Server.

Log in to the web interface of your Proxmox VE host and navigate to:

Datacenter → Storage → Add → Proxmox Backup Server

Then fill in the required fields with the following information:

ID: pbs-nas-smb
Server: Your PBS IP address, for example 10.4.4.20
Username: root@pam
You can also create and use a dedicated user account on the PBS if preferred
Password: The password for root@pam
Datastore: nas-smb-store
Namespace: As described in the previous step, for example Server01
Fingerprint: You can find this on the PBS dashboard, roughly in the middle of the page under Show FingerprintCode-Sprache: CSS (css)

The datastore is now available. When you select a virtual machine or container, you can open the Backup tab, click Backup now, and choose the newly added storage target.

Completed backups will not appear in the per-VM Backup list. Instead, you can find them under:

Datacenter → pbs-nas-smb → Backups

You can configure scheduled backup jobs globally under:

Datacenter → Backup

For example, you can create a job to run a full backup of all clients every Saturday at 1:00 AM

Retain a maximum of one backup version.

That’s it. Verify that temporary snapshots are removed correctly after each job and that the backups complete successfully.

Backing Up Your Proxmox Backup Server

Shut down the virtual machine in Hyper-V.

Then:

Right-click the VM and select Export
Choose the target location
Click Export
Once the export is complete, start the virtual machine againCode-Sprache: JavaScript (javascript)

OPNsense Installation

After booting, log in using
User: installer
Password opnsense

Then proceed using the default keymap.

Choose „Install ZFS“ (this allows you to create snapshots later).

Select your virtual disk as the target for the installation.

Confirm that the data on the disk may be erased once you have verified that it is the correct virtual disk and that deleting the data is acceptable.

The installation has started and is now in progress.

Click Complete Install. The password can be changed later in the GUI.

Now perform a restart to complete the installation.

Afterwards, you can remove the ISO:

Open the VM console in Proxmox and log in to OPNsense after the reboot.

User: root
Password: opnsense

Then select option 1 – Assign interfaces.

Now the assignment will be applied. If in doubt, double-check your MAC address against the one in the VM configuration. You can see the names of the interfaces and NICs in the field above in your console.”

Let’s skip the DMZ for now

Connect your client directly to LAN3 (NIC2) using a network cable and assign it the IP address 192.168.1.10 with subnet mask 255.255.255.0. You can leave the gateway and DNS fields empty. Once the configuration is complete (e.g., DHCP server configured), you can switch your device back to DHCP. Below is an example of the network settings for Windows:

You should now be able to log in to the OPNsense web interface using your browser at https://192.168.1.1

Use the following credentials:

Username: root
Password: opnsense

Once logged in, you will have access to the full GUI to continue configuration and management of your OPNsense firewall.”

Creating an OPNsense VM in Proxmox

Download the DVD ISO image from the OPNsense website: Download – OPNsense (external Link)

choose the DVD type:

In Proxmox, upload the ISO to Datacenter → local → ISO Images

Once the upload is complete, we can create the VM. To do this, click ‚Create VM‘ in the top-right corner.

Add a name for example, vFW01 or FW02, …

Select the OPNsense ISO image and set the Guest OS type to ‚Other‘, then click ‚Next‘

Next

Set up your virtual disk as a VirtIO block with at least 8 GB.

CPU config Type „host“

Set up your memory with at least 1024 MB.

Now add LAN2 (NIC1) as your network interface for the WAN.

Confirm and finish, but do not start the VM yet

Return to the VM, navigate to Hardware → Add → Network Device, and add LAN3 (NIC2) for the LAN interface and optional the LAN4 (NIC3) for the DMZ interface

In the VM options, enable autostart

Now Start the VM and open the console

Next Step: OPNsense Installation

Proxmox OPNsense config

This guide explains how to preconfigure Proxmox VE to run an OPNsense firewall as a virtual machine. Proper preparation of the Proxmox host is essential to ensure secure network segmentation, reliable performance, and a smooth OPNsense installation.

You will learn how to set up network bridges, assign physical interfaces, and apply best‑practice settings that allow OPNsense to operate as a fully functional virtual firewall. The focus is on creating a clean and flexible foundation that can be adapted to both lab environments and production use.

By completing these preconfiguration steps, Proxmox will be ready to host OPNsense efficiently, giving you full control over routing, firewalling, and network security within your virtualized infrastructure.

Now let’s finish setting up Proxmox.

First, we need to assign the physical network interfaces to OPNsense:

  • 1× WAN port
  • 1× LAN port with VLANs
  • 1× DMZ port (optional, if you plan to use a DMZ)
LAN2 (nic1) → OPNsense WAN → directly connected to your existing router

LAN3 (nic2) → LAN with VLANs → connected to your VLAN-capable switch

LAN4 (nic3) → DMZ → for example, directly connected to your NAS or other Switch (optional)Code-Sprache: JavaScript (javascript)

Creating the bridges in Proxmox

Go to System → Network and click Create → Linux Bridge.

You only need to create bridges for the three required interfaces:

  • WAN
  • LAN
  • DMZ (optional)

Start with:

WAN:

LAN (with multiple VLANs for network segmentation):

Example VLAN IDs: 200, 300, 400, 500 — each VLAN represents a separate network with its own services.

DMZ (optional):

A DMZ (Demilitarized Zone) is a separate network segment used to host services that need to be accessible from outside the internal network, while keeping the internal LAN isolated and protected.

Then click Apply Configuration

The switch port connected to the OPNsense LAN interface requires the following VLAN configuration:

1U (untagged)
200T (tagged)
300T (tagged)
400T (tagged)

Next Step: Creating an OPNsense VM in Proxmox

Network preparations

Taking over the existing network only requires changing the router’s IP address, for example from 10.10.1.1 to 10.11.1.1.

Example IPs

Network Evolution: From Flat Network to Virtualized Firewall

The diagram illustrates the transformation of a network architecture across three stages: a basic network without a firewall, a setup with a physical OPNsense firewall, and finally a fully virtualized OPNsense firewall running on Proxmox.

1. Existing Network (Left)

On the left side, the network operates without a dedicated firewall. The internet connection is terminated directly at a router using the subnet 10.10.1.1/24. This router forwards traffic straight to a switch, which connects all internal devices.

In this setup, routing and basic protection are handled solely by the router. There is no network segmentation, no advanced firewalling, and limited control over traffic flows between the internet and the internal network.


2. Network with Physical OPNsense Firewall (Middle)

In the middle scenario, a physical OPNsense firewall is introduced between the router and the internal network. The router now uses a separate network (10.11.1.0/24) and forwards traffic to the OPNsense firewall via 10.11.1.2 (WAN OPNsense Port).

The OPNsense firewall becomes the new gateway for the internal network (LAN) (10.10.1.1). All traffic between the internal switch and the internet must pass through OPNsense, enabling stateful firewalling, NAT, traffic inspection, and advanced security policies.

This design significantly improves security and control but requires dedicated hardware for the firewall.


3. Network with Virtualized OPNsense on Proxmox (Right)

On the right side, the physical firewall is replaced by a virtual OPNsense instance running on a Proxmox host. The router and WAN configuration remain the same, but OPNsense is now hosted as a virtual machine inside Proxmox (10.10.1.21).

Proxmox acts as the virtualization layer, connecting the WAN and LAN through virtual bridges. The OPNsense VM still serves as the default gateway (10.10.1.1) for the internal network, enforcing the same security policies as the physical firewall.

This approach combines the security benefits of OPNsense with the flexibility of virtualization, reducing hardware requirements while enabling easier backups, snapshots, and scalability.

Next step: Proxmox OPNsense config

Install Proxmox VE on the Protectli Appliance

for this demonstration, a Protectli VP2430 (4 × 2.5G ports) is used as the hardware platform.

While virtualized firewalls are not always the preferred approach in production environments, this guide addresses the common request to run OPNsense on top of Proxmox VE.

If you prefer to install OPNsense directly on the appliance instead of virtualizing it, you may skip the Proxmox steps below and create a bootable USB drive with the OPNsense image instead.

nic0 – LAN1: LAN Proxmox
nic1 – LAN2: WAN OPNsense
nic2 – LAN3: LAN OPNsense (VLAN Switch)
nic3 – LAN4: DMZ OPNsense

Requirements

  • A USB flash drive (8 GB or larger recommended)
  • Proxmox VE ISO image
  • A tool to create a bootable USB drive (e.g., balenaEtcher)
  • USB keyboard and mouse
  • Monitor
  • Network connection (LAN1 / nic0)

Download the Proxmox VE ISO installer from the official website (Download Proxmox software, datasheets, agreements) <- external LINK.

Step 1 – Create a Bootable USB Drive

  1. Download the latest Proxmox VE ISO.
  2. Use your preferred tool (e.g., balenaEtcher) to write the ISO image to a USB flash drive.
  3. Safely eject the USB drive once the process is complete.

Step 2 – Connect the Hardware

  1. Insert the bootable USB drive into the Protectli appliance.
  2. Connect a USB keyboard, USB mouse, and a monitor.
  3. Connect your network cable to LAN1 (nic0).

Step 3 – Boot from USB

  1. Power on the device.
  2. Enter the boot menu (commonly via F11, depending on BIOS).
  3. Select the USB device as the boot source.
  4. Choose “Install Proxmox VE (Graphical)” from the menu.

Step 4 – Installation Process

  1. Accept the EULA after reviewing it.
  2. Select the target disk for installation and click Next.
  3. Choose:
    • Country
    • Time zone
    • Keyboard layout
      Then click Next.
  4. Set a secure root password.
  5. Enter a valid email address for system notifications.
  6. Click Next.

Step 5 – Network Configuration

If LAN1 is connected to a network with an active DHCP server, an IP address may already be assigned automatically.

It is strongly recommended to:

  • Configure a hostname + static IP address
  • Ensure it is outside the DHCP range
  • Set the correct Gateway (GW)
  • Configure a DNS server

Since OPNsense will later handle DHCP services inside Proxmox, a static management IP ensures consistent access.

Click Next after entering the settings.


Step 6 – Final Review & Installation

  1. Review the summary.
  2. If everything is correct, click Install.
  3. Wait for the installation to complete.
  4. Reboot the system when prompted.

Accessing the Proxmox Web Interface + detach your USB Stick 😉

After reboot, access the Proxmox management interface via:

https://YOUR-IP-ADDRESS:8006Code-Sprache: PHP (php)

Login credentials:

  • Username: root
  • Password: The password defined during installation

Once the web interface is accessible via your browser, you can disconnect the keyboard, mouse, and monitor from the appliance. The system can then be managed entirely through the Proxmox web interface.

Next step: Network preparations

DIY ESPHome Salt Level Sensor for a Water Softener (D1 Mini Pro + VL53L0X)

I built a simple salt level sensor for a water softener using a Wemos D1 Mini Pro and a VL53L0X time-of-flight distance sensor, fully integrated with ESPHome and Home Assistant. The sensor measures the distance to the salt surface inside the brine tank and converts it into salt height (cm) and fill level (%). In this post I’m sharing the complete parts list, wiring (I²C on D1/D2), and the working ESPHome configuration, so anyone can replicate the setup and calibrate it easily for their own tank depth.

Connections

VL53L0X Pin D1 Mini Pro PinNotes
VIN3V3Use 3.3V for best stability
GNDGNCCommon ground
SDAD2I²C data
SCLD1I²C clock

Keep the I²C wires short (ideally < 20–30 cm) to avoid unstable readings.

If you must use longer wires, reduce the I²C frequency in ESPHome (e.g. frequency: 20000) and/or add 4.7k pull-ups from SDA→3V3 and SCL→3V3 (only if your breakout board doesn’t already have them).

Make sure the sensor has a clear line of sight to the salt surface (no plastic window in between, and mounted as straight as possible).

ESPHome YAML:

esphome:
  name: softliq-salt-v3
  friendly_name: softliq-salt-v3

esp8266:
  board: d1_mini_pro

logger:

api:
  encryption:
    key: "YOURENCRYPTIONKEY"

ota:
  - platform: esphome
    password: "YOURPASSWORD"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Softliq-Salt-V3 Fallback Hotspot"
    password: "YOURWIFIFALLBACKHOTSPOTPASSWORD"

captive_portal:

# I2C: D2=SDA (GPIO4), D1=SCL (GPIO5)
i2c:
  sda: D2
  scl: D1
  scan: true
  frequency: 20000

sensor:
  # Abstand Sensor -> Salzoberfläche (VL53L0X liefert Meter -> cm)
  - platform: vl53l0x
    name: "Salztank Abstand"
    id: salt_distance_cm
    address: 0x29
    update_interval: 180s
    unit_of_measurement: "cm"
    accuracy_decimals: 1
    # optional: stabiler, aber etwas langsamer
    # long_range: true
    filters:
      - multiply: 100.0
      - sliding_window_moving_average:
          window_size: 3
          send_every: 1
      - clamp:
          min_value: 0.0
          max_value: 60.0

  # Salzhöhe in cm (0..50)
  - platform: template
    name: "Salztank Salzhöhe"
    unit_of_measurement: "cm"
    accuracy_decimals: 1
    update_interval: 180s
    lambda: |-
      const float TANK_DEPTH_CM = 60.0;

      // KALIBRIERUNG:
      const float EMPTY_DISTANCE_CM = 60.0;  // leer (Sensor->Boden)
      const float FULL_DISTANCE_CM  = 10.0;   // voll (Sensor->Salz)

      float d = id(salt_distance_cm).state;
      if (isnan(d)) return NAN;

      float height = EMPTY_DISTANCE_CM - d;
      if (height < 0) height = 0;
      if (height > TANK_DEPTH_CM) height = TANK_DEPTH_CM;
      return height;

  # Füllstand in %
  - platform: template
    name: "Salztank Füllstand"
    unit_of_measurement: "%"
    accuracy_decimals: 0
    update_interval: 5s
    lambda: |-
      const float EMPTY_DISTANCE_CM = 60.0; // leer
      const float FULL_DISTANCE_CM  = 10.0;  // voll

      float d = id(salt_distance_cm).state;
      if (isnan(d)) return NAN;

      float denom = (EMPTY_DISTANCE_CM - FULL_DISTANCE_CM);
      if (denom <= 0.01) return NAN;

      float pct = (EMPTY_DISTANCE_CM - d) / denom * 100.0;
      if (pct < 0) pct = 0;
      if (pct > 100) pct = 100;
      return pct;
Code-Sprache: PHP (php)

The brine tank height is 60 cm, and the “empty” reference distance is 10 cm.

Attached you’ll find the templates for your 3D printer in STL format: one set for the Wemos D1 Mini Pro enclosure (housing + lid) and one set for the sensor enclosure (housing + lid). If your sensor board doesn’t have a protective cover, I recommend gluing a transparent acrylic (plexiglass) sheet on top to protect it from salt mist and deposits.

Download

Some picture:

Home Assistant:

How-To: Split DNS for Wi-Fi Calling: Resolve 3gppnetwork.org via German DNS while keeping global upstreams

Wi-Fi Calling on Telekom/Vodafone only works reliably for me when the relevant IMS/ePDG hostnames are resolved via German DNS servers. Since my network uses a non-German upstream DNS by default, I implemented split DNS so that only 3gppnetwork.org (Wi-Fi Calling related) is resolved via German DNS, while everything else continues to use the usual upstream. In this post you’ll find step-by-step instructions for both Pi-hole and AdGuard Home.

PiHole v6+

PiHole -> Settings -> System: enable expert check box

PiHole -> Settings -> All settings: enable all settings

PiHole -> Settings -> All settings -> Miscellaneous

Find: misc.dnsmasq_lines

add:

server=/3gppnetwork.org/GERMANDNS1
server=/3gppnetwork.org/GERMANDNS2
server=/pub.3gppnetwork.org/GERMANDNS1
server=/pub.3gppnetwork.org/GERMANDNS2Code-Sprache: JavaScript (javascript)

Replace GERMANDNS1 und GERMANDNS2 with the IP from your German DNS (for example your ISP DNS)

AdGuardHome

Einstellungen -> DNS Einstellungen

Upstream-DNS-Server

add:

[/3gppnetwork.org/]GERMANDNS1 GERMANDNS2Code-Sprache: JavaScript (javascript)

Replace GERMANDNS1 und GERMANDNS2 with the IP from your German DNS (for example your ISP DNS)

ESPHome 2.9″ Waveshare ePaper (296×128): Clean PV Flow Layout

I’m using a 2.9″ Waveshare ePaper (296×128) on an ESP8266 D1 Mini Pro with ESPHome, pulling all values from Home Assistant
– PV power (sensor.pv_gesamt)
– house consumption (sensor.strom_eigengebrauch)
– grid import/export (sensor.s10x_consumption_from_grid, sensor.s10x_export_to_grid)
– battery charge/discharge + SOC (sensor.s10x_battery_charge, sensor.s10x_battery_discharge, sensor.s10x_state_of_charge).

esp8266:
  board: d1_mini_pro

Script:

captive_portal:

font:
  - file: "gfonts://Roboto+Mono@300"
    id: font_value
    size: 16

  - file: "gfonts://Roboto+Mono@300"
    id: font_home
    size: 26

  - file: "gfonts://Roboto+Mono@300"
    id: font_arrow
    size: 16

  - file: "gfonts://Material+Symbols+Outlined"
    id: icons_28
    size: 28
    glyphs:
      [
        "\U0000e88a", # home
        "\U0000ec0f", # sun
        "\U0000f102", # grid/power (Versorger-Ersatz)
        "\U0000e1a4", # battery 100
        "\U0000ebd2",
        "\U0000ebd4",
        "\U0000ebe2",
        "\U0000ebdd",
        "\U0000ebe0",
        "\U0000ebd9",
        "\U0000ebdc"  # battery 0
      ]

  - file: "gfonts://Material+Symbols+Outlined"
    id: icons_52
    size: 52
    glyphs: ["\U0000e88a"]

spi:
  clk_pin: D0
  mosi_pin: D1

display:
  - platform: waveshare_epaper
    cs_pin: D2
    dc_pin: D3
    busy_pin: D4
    reset_pin: D5
    model: 2.90inv2-r2
    rotation: 90
    reset_duration: 2ms
    update_interval: 15s

    lambda: |-
      const float TH = 0.02f; // 20W (in kW)

      const float pv_kw    = id(pv_gesamt).state;
      const float home_kw  = id(stromverbrauch).state;

      const float grid_in  = id(netzbezug).state;
      const float grid_out = id(einspeisung).state;
      const float grid_net = grid_in - grid_out;

      const float bat_in   = id(inbatterie).state;
      const float bat_out  = id(outbatterie).state;
      const float bat_net  = bat_out - bat_in;

      const float soc      = id(batterieload).state;

      auto fmt_kw = [&](float kw) -> std::string {
        if (isnan(kw)) return std::string("--");
        float w = kw * 1000.0f;
        char buf[18];
        if (fabsf(w) < 1000.0f) snprintf(buf, sizeof(buf), "%.0fW", w);
        else                    snprintf(buf, sizeof(buf), "%.2fkW", kw);
        return std::string(buf);
      };

      auto dotted_h = [&](int x1, int y, int x2, int step, int on) {
        if (x2 < x1) { int t=x1; x1=x2; x2=t; }
        for (int x = x1; x <= x2; x += step) {
          it.line(x, y, std::min(x + on, x2), y);
        }
      };

      auto arrow_right = [&](int x, int y) { it.filled_triangle(x, y, x-7, y-4, x-7, y+4); };
      auto arrow_left  = [&](int x, int y) { it.filled_triangle(x, y, x+7, y-4, x+7, y+4); };

      auto flow_h = [&](int x1, int y, int x2, bool head_at_end) {
        dotted_h(x1, y, x2, 8, 3);
        if (head_at_end) {
          if (x2 > x1) arrow_right(x2, y);
          else         arrow_left(x2, y);
        } else {
          if (x2 > x1) arrow_left(x1, y);
          else         arrow_right(x1, y);
        }
      };

      auto midx = [&](int a, int b) -> int { return a + (b - a) / 2; };

      // Batterie-Icon nach SOC
      std::string battery_icon = "\U0000ebdc";
      if (soc >= 95.0)      battery_icon = "\U0000e1a4";
      else if (soc >= 85.0) battery_icon = "\U0000ebd2";
      else if (soc >= 70.0) battery_icon = "\U0000ebd4";
      else if (soc >= 55.0) battery_icon = "\U0000ebe2";
      else if (soc >= 40.0) battery_icon = "\U0000ebdd";
      else if (soc >= 25.0) battery_icon = "\U0000ebe0";
      else if (soc >= 10.0) battery_icon = "\U0000ebd9";

      // ===== Layout (296x128) =====
      const int HOME_X  = 120;
      const int HOME_Y  = 28;
      const int HOME_CX = 150;

      // Pfeil-Y Positionen
      const int Y_PV   = 32;
      const int Y_GRID = 56;
      const int Y_BAT  = 92;

      // Icons
      const int PV_ICON_X  = 6;
      const int PV_ICON_Y  = 4;

      const int BAT_ICON_X = 6;
      const int BAT_ICON_Y = 74;
      const int BAT_CX     = BAT_ICON_X + 14;

      const int GRID_ICON_X = 268;
      const int GRID_ICON_Y = 44;

      const int X_HOME_LEFT  = HOME_CX - 18;
      const int X_HOME_RIGHT = HOME_CX + 28;

      const int L = 84;
      const int X_LEFT_FLOW  = X_HOME_LEFT - L;
      const int X_RIGHT_FLOW = X_HOME_RIGHT + L;

      const int LABEL_DY = 20;   // <-- war 16

      // ===== Zeichnen =====
      it.printf(PV_ICON_X, PV_ICON_Y, id(icons_28), "\U0000ec0f");

      it.printf(BAT_ICON_X, BAT_ICON_Y, id(icons_28), "%s", battery_icon.c_str());
      char soc_buf[8];
      snprintf(soc_buf, sizeof(soc_buf), "%.0f%%", soc);
      it.printf(BAT_CX, BAT_ICON_Y + 30, id(font_value), TextAlign::TOP_CENTER, "%s", soc_buf);

      it.printf(HOME_X, HOME_Y, id(icons_52), "\U0000e88a");

      it.printf(HOME_CX, 88, id(font_home), TextAlign::TOP_CENTER, "%s", fmt_kw(home_kw).c_str());

      it.printf(GRID_ICON_X, GRID_ICON_Y, id(icons_28), "\U0000f102");

      // PV -> Home
      if (!isnan(pv_kw) && pv_kw > TH) flow_h(X_LEFT_FLOW, Y_PV, X_HOME_LEFT, true);
      else                             dotted_h(X_LEFT_FLOW, Y_PV, X_HOME_LEFT, 8, 3);

      float pv_show = (!isnan(pv_kw) && pv_kw > TH) ? pv_kw : 0.0f;
      it.printf(midx(X_LEFT_FLOW, X_HOME_LEFT), Y_PV - LABEL_DY, id(font_arrow),
                TextAlign::TOP_CENTER, "%s", fmt_kw(pv_show).c_str());

      // Akku <-> Home
      bool bat_active = (!isnan(bat_net) && fabsf(bat_net) > TH);
      if (bat_active) {
        if (bat_net > 0) flow_h(X_LEFT_FLOW, Y_BAT, X_HOME_LEFT, true);
        else             flow_h(X_HOME_LEFT, Y_BAT, X_LEFT_FLOW, true);
      } else {
        dotted_h(X_LEFT_FLOW, Y_BAT, X_HOME_LEFT, 8, 3);
      }

      float bat_show = (bat_active) ? fabsf(bat_net) : 0.0f;
      it.printf(midx(X_LEFT_FLOW, X_HOME_LEFT), Y_BAT - LABEL_DY, id(font_arrow),
                TextAlign::TOP_CENTER, "%s", fmt_kw(bat_show).c_str());

      // Netz <-> Home
      bool grid_active = (!isnan(grid_net) && fabsf(grid_net) > TH);
      if (grid_active) {
        if (grid_net > 0) flow_h(X_RIGHT_FLOW, Y_GRID, X_HOME_RIGHT, true);
        else              flow_h(X_HOME_RIGHT, Y_GRID, X_RIGHT_FLOW, true);
      } else {
        dotted_h(X_HOME_RIGHT, Y_GRID, X_RIGHT_FLOW, 8, 3);
      }

      float grid_show = (grid_active) ? fabsf(grid_net) : 0.0f;
      it.printf(midx(X_HOME_RIGHT, X_RIGHT_FLOW), Y_GRID - LABEL_DY, id(font_arrow),
                TextAlign::TOP_CENTER, "%s", fmt_kw(grid_show).c_str());

sensor:
  - platform: homeassistant
    id: stromverbrauch
    entity_id: sensor.strom_eigengebrauch
  - platform: homeassistant
    id: pv_gesamt
    entity_id: sensor.pv_gesamt
  - platform: homeassistant
    id: netzbezug
    entity_id: sensor.s10x_consumption_from_grid
  - platform: homeassistant
    id: einspeisung
    entity_id: sensor.s10x_export_to_grid
  - platform: homeassistant
    id: inbatterie
    entity_id: sensor.s10x_battery_charge
  - platform: homeassistant
    id: outbatterie
    entity_id: sensor.s10x_battery_discharge
  - platform: homeassistant
    id: batterieload
    entity_id: sensor.s10x_state_of_charge

web_server:
  port: 80Code-Sprache: PHP (php)

Preview:

Sync Your Tapo TP-Link L920-5 RGBIC LED Strip with PS5 Game Covers in Home Assistant

I built a Home Assistant automation that syncs a TP-Link Tapo L920-5 (5m RGBIC) LED strip with my PlayStation 5. Whenever the PS5 starts playing a game, Home Assistant grabs the game cover art (entity picture) and extracts a color palette from it, then pushes a short RGBIC color sequence to the LED strip. The result is a simple “ambilight-style” effect that matches the game you’re currently playing.

In this post I’ll share the full setup: required entities, the palette extraction step, and the Home Assistant automation/service call to run the color sequence. It’s lightweight, fully local on the HA side (apart from fetching the cover image), and you can tweak brightness, transition speed, and number of colors easily.

Requirements: You’ll need the PlayStation Network integration in Home Assistant to provide the “now playing” game cover (entity picture). My setup runs on Home Assistant in Docker, and I recommend Home Assistant 2025.12 or newer.

entity_id=image.psn_account_spielt_gerade
led: light.ledstripe01

Let’s start with Home Assistant REST API

Edit your configuration.yaml and add a REST Sensor:

    rest:                                          
      - resource: "http://<strong>IPORHOSTNAMEFROMDOCKERCONTAINER</strong>:8787/palette?entity_id=image.psn_account_spielt_gerade&k=6"
        scan_interval: 20                            
        sensor:                                               
          - name: psn_logo_palette           
            value_template: "{{ value_json.dominant_hex }}"
            json_attributes:                                                                                                                                        
              - sequence                                                                                                                                             
              - brightness                                                                                                                                           
              - transition                                                          
              - spread                       
              - directionCode-Sprache: HTML, XML (xml)

    Replace IPORHOSTNAMEFROMDOCKERCONTAINER with the IP address or hostname of your Docker container (see the next step).

    Create Docker Container

    First, we need a python script

    sudo mkdir /opt/palette-api/
    sudo touch /opt/palette-api/palette_api.py
    sudo vi /opt/palette-api/palette_api.py
    def extract_palette(img: Image.Image, k: int = 6):
        img = img.convert('RGB').resize((260, 260))
    
        # 1) Crop: 12% Rand weg (UI/Badges/Border reduzieren)
        w, h = img.size
        m = int(min(w, h) * 0.12)
        img = img.crop((m, m, w - m, h - m))
    
        # 2) Quantize -> Kandidaten
        q = img.quantize(colors=96, method=2)
        pal = q.getpalette()
        counts = q.getcolors() or []
        counts.sort(reverse=True, key=lambda x: x[0])
    
        candidates = []
        for cnt, idx in counts[:96]:
            rgb = (pal[idx*3], pal[idx*3+1], pal[idx*3+2])
            h, s, v = rgb_to_hsv_triplet(rgb)
    
            # 3) Filter: zu grau/zu dunkel/zu hell rauswerfen
            if s < 12:
                continue
            if v < 8 or v > 98:
                continue
    
            candidates.append((cnt, rgb, h, s, v))
    
        if not candidates:
            return [(0, 120, 255)] * k, 200  # fallback blau
    
        # 4) Auswahl: Hue-divers + RGB-divers (damit Blau/Rot/Highlights reinkommen)
        picked = []
        picked_hues = []
    
        def hue_dist(a, b):
            d = abs(a - b) % 360
            return min(d, 360 - d)
    
        for cnt, rgb, h, s, v in candidates:
            if any(hue_dist(h, ph) < 22 for ph in picked_hues):
                continue
            if any(sum((a-b)**2 for a, b in zip(rgb, prgb)) < 800 for prgb in picked):
                continue
            picked.append(rgb)
            picked_hues.append(h)
            if len(picked) >= k:
                break
    
        # Wenn wir noch nicht genug haben: ohne Hue-Regel auffuellen
        if len(picked) < k:
            for cnt, rgb, h, s, v in candidates:
                if any(sum((a-b)**2 for a, b in zip(rgb, prgb)) < 600 for prgb in picked):
                    continue
                picked.append(rgb)
                if len(picked) >= k:
                    break
    
        while len(picked) < k:
            picked.append(picked[-1])
    
        # dominanter Hue = haeufigste (nach Filter)
        dominant_hue = candidates[0][2]
        return picked, dominant_hue
    
    Code-Sprache: PHP (php)

    now create the Docker Container for example Portainer -> Create new stack (Web), Name: palette-api

    version: "3.8"
    
    services:
      palette-api:
        image: python:3.11
        container_name: palette-api
        restart: unless-stopped
    
        networks:
          <strong>IOT-600</strong>:
            ipv4_address: <strong>192.168.1.60</strong>
    
        environment:
          HA_BASE_URL: "http://<strong>IPFROMYOURHOMEASSISTANTSERVER</strong>:8123"
          HA_TOKEN: "<strong>PASTE_YOUR_TOKEN_HERE</strong>"
    
        volumes:
          - /opt/palette-api/palette_api.py:/app/palette_api.py:ro
    
        command: >
          bash -lc "
          pip install --no-cache-dir fastapi uvicorn pillow requests &&
          uvicorn palette_api:app --app-dir /app --host 0.0.0.0 --port 8787
          "
    
    networks:
      <strong>IOT-600</strong>:
        external: true
    Code-Sprache: HTML, XML (xml)

    To make this work, you need a Long-Lived Access Token from Home Assistant and the hostname/IP of your Home Assistant server. Then you paste both into the container environment variables:

    Get your Home Assistant Long-Lived Access Token

    • Open Home Assistant in your browser.
    • Click your profile (bottom-left in the sidebar) — or go to Settings → People → Users → (your user).
    • Scroll down to Long-Lived Access Tokens.
    • Click Create Token, give it a name (e.g., palette-api), and confirm.
    • Copy the token immediately (you won’t be able to see it again later).
    • Now paste it into:

    HA_TOKEN: „PASTE_YOUR_TOKEN_HERE“

    Set the Home Assistant base URL (Hostname or IP)

    HA_BASE_URL must point to the URL where the Docker container can reach Home Assistant:

    If Home Assistant is on the same machine/LAN, use its LAN IP or hostname, e.g.
    http://192.168.1.50:8123 or http://homeassistant.local:8123

    Paste it into:

    HA_BASE_URL: „http://192.168.1.50:8123“

    Notes / common pitfalls

    Don’t use localhost unless the container runs in the same network namespace as Home Assistant (usually it won’t).

    Keep the token secret (don’t post it in screenshots/logs).

    If you use HTTPS externally, you can also set https://… — but the key thing is that the container must be able to reach that address.

    Update the network configuration to fit your setup. I use a macvlan network (IOT-600) because the container needs a separate IP/subnet from the Docker host—specifically an isolated IoT VLAN/network in my environment. If you don’t need that, switch to your preferred Docker networking mode.

    Now we have the palette-api Python script, the Docker container, and the REST API sensor(s) set up in Home Assistant. Next, validate your configuration (configuration.yaml) and then restart Home Assistant to make sure everything is loaded correctly.

    Once Home Assistant is back up, the last missing piece is the automation that triggers the palette extraction and sends the resulting color sequence to your lights.

    Home Assistant Automation:

    alias: "PSN -> LEDStripe: Sequence + Off on End"
    description: ""
    triggers:
      - entity_id: sensor.psn_account_spielt_gerade
        trigger: state
    actions:
      - variables:
          led: light.ledstripe01
          game_title: "{{ states('sensor.psn_account_spielt_gerade') | trim }}"
          ended: >-
            {{ game_title in ['unknown','unavailable','', None, 'None', 'none',
            'idle', 'standby', 'offline'] }}
      - choose:
          - conditions:
              - condition: template
                value_template: "{{ ended }}"
            sequence:
              - target:
                  entity_id: "{{ led }}"
                action: light.turn_off
              - stop: Game ended -> LED off
      - delay: "00:00:02"
      - target:
          entity_id: sensor.psn_logo_palette
        action: homeassistant.update_entity
      - delay: "00:00:02"
      - variables:
          seq: >-
            {{ state_attr('sensor.psn_logo_palette', 'sequence') | default([], true)
            }}
          bri: >-
            {{ state_attr('sensor.psn_logo_palette', 'brightness') | default(80,
            true) | int }}
          trans: >-
            {{ state_attr('sensor.psn_logo_palette', 'transition') | default(6000,
            true) | int }}
          spread: >-
            {{ state_attr('sensor.psn_logo_palette', 'spread') | default(3, true) |
            int }}
          direction: >-
            {{ state_attr('sensor.psn_logo_palette', 'direction') | default(1, true)
            | int }}
      - condition: template
        value_template: "{{ seq is iterable and (seq | length) >= 6 }}"
      - target:
          entity_id: "{{ led }}"
        data:
          sequence: "{{ seq }}"
          segments: 0
          brightness: "{{ bri }}"
          repeat_times: 0
          transition: "{{ trans }}"
          spread: "{{ spread }}"
          direction: "{{ direction }}"
        action: tplink.sequence_effect
    mode: restart
    Code-Sprache: JavaScript (javascript)