Category Archives: IOT / Domoticz

Giving people ability to control my Xmas lights

At the WHY2025 hackers event, we used bigred’s antenna pole to make a huge sign using leds.

It gave me the idea to make a pole which displays digital “fireworks” using leds.
Fireworks are becoming illegal the next year in the Netherlands, I think.

So why not going digital?
12 Meter pole, 300 Leds.

There is a QR code and a website link mentioned on a page at the gate.
10 presets to select via a website.

Notification on my TV

While waiting for the first visitor I made a magnetic game.
Using a bunch of magnets, and 3D printed hexagon rings.
Two players try to place magnets in a small area without moving the others. If magnets slam together, you have more pieces to place.
(Below, last player has to take those 6 pieces)

CODE for website index.html

<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WLED Mast</title>

<style>
body {
    background: #111;
    color: #fff;
    font-family: Arial;
    margin: 0;
    padding: 20px;
    text-align: center;
}
.grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 18px;
}
button {
    padding: 25px;
    font-size: 22px;
    border-radius: 20px;
    border: none;
    background: #333;
    color: #fff;
    font-weight: bold;
}
button:active {
    background: #555;
}
</style>
</head>

<body>

<h1>WLED Mast</h1>

<div class="grid">
    <button onclick="run('b1')">off</button>
    <button onclick="run('b2')">fireworks</button>
    <button onclick="run('b3')">sound<BR>reactive</button>
    <button onclick="run('b4')">bounch-balls</button>
    <button onclick="run('b5')">fire</button>
    <button onclick="run('b6')">juggle</button>
    <button onclick="run('b7')">swing</button>
    <button onclick="run('b8')">sparkle</button>
    <button onclick="run('b9')">popcorn</button>
    <button onclick="run('b10')">comet</button>
</div>

<script>
const API_KEY = "misuse-prevent";

function run(action) {
    fetch(`/action.php?action=${action}&key=${API_KEY}`)
        .then(r => r.json())
        .then(j => console.log(j))
        .catch(err => alert("Error"));
}
</script>

</body>
</html>

CODE for Wled API

<?php

$public_api_key = "misuse-prevent";
if (!isset($_GET['key']) || $_GET['key'] !== $public_api_key) {
    http_response_code(401);
    die("Unauthorized");
}

$action = $_GET['action'] ?? null;

// Map 10 buttons → WLED preset numbers
$allowed_actions = [
    "b1"  => 19,
    "b2"  => 22,
    "b3"  => 23,
    "b4"  => 20,
    "b5"  => 21,
    "b6"  => 3,
    "b7"  => 8,
    "b8"  => 2,
    "b9"  => 4,
    "b10" => 14
];

if (!$action || !isset($allowed_actions[$action])) {
    http_response_code(400);
    die("Invalid action");
}

$preset = $allowed_actions[$action];

// ---- WLED IP address ----
$wled_ip = "http://WLEDDEVICEIP";   // <-- CHANGE THIS

// API endpoint
$url = $wled_ip . "/win&PL=" . $preset;

// Call WLED directly
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
curl_setopt($ch, CURLOPT_TIMEOUT, 2);

$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);

header("Content-Type: application/json");

if ($error) {
    echo json_encode(["ok" => false, "error" => $error]);
} else {
    echo json_encode(["ok" => true, "preset" => $preset]);
}

Working on the garden lights POC

Working on my garden lights

Working 12V relay bottom left, and upper right the Raspberry $ compute module board with NodeRed.

I made a little board to program the ATTiny85.

The RS485 chip I wanted to use (SN65HVD3082) came as SMD, luckily I have some SMD to THT/DIL boards. (breakoff)

Above on the breadboard : The SN65HVD3082EP on a little pcb, the ATTiny85 .
4×4 WS2812 led matrix will be my dimmable RGB garden light.

New MMWave boards.

Several years ago, I used some radar boards to detect movement.

After that I used a MMWave board

These are amazing, I’m using this in my Lab to switch on my big LED panel. You don’t need to move, it will detect human presence.
Another one I’m using in my living room.
It can power off all media at night (like TV, amplifier and lights)
It’s part of my home alarm system when we are away.

Now I got a new MMWave sensor (hlk-ld2450) , it can detect up to 3 persons and their location.


Screenshot from a Bluetooth phone app reading the sensor.

New afvalwijzer using Home Assistant

New version of :

New schematic using ESPHOME and has reset button (pressing the lid).
(Resets the LED when you’ve put the trash at the street curb.)

Above, a simplified version. I’m using 4 ws2812 leds now.

ESPHOME code:

esphome:
  name: afvalwemos
  friendly_name: AfvalWemos
  on_boot:
    priority: 800
    then:
    - delay: 1s
    - light.turn_off: main_leds

esp8266:
  board: d1_mini

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "bT76oR8vOxxxxxxxxxxxxxxxxxxxxxxxxQbyjY6M="

ota:
  - platform: esphome
    password: "c1dbxxxxxxxxxxxxxxxxxxxxxxxxxx36e75"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Afvalwemos Fallback Hotspot"
    password: "vVxxxxxxxxxxxxxxaw"

captive_portal:

# LED strip definition
light:
  - platform: neopixelbus
    type: RGB
    variant: WS2812
    pin: D5
    num_leds: 4
    name: "All LEDs"
    id: main_leds
    restore_mode: ALWAYS_OFF   # ensures all LEDs start off


  # Individual LED partitions
  - platform: partition
    name: "LED 1"
    segments:
      - id: main_leds
        from: 0
        to: 0

  - platform: partition
    name: "LED 2"
    segments:
      - id: main_leds
        from: 1
        to: 1

  - platform: partition
    name: "LED 3"
    segments:
      - id: main_leds
        from: 2
        to: 2

  - platform: partition
    name: "LED 4"
    segments:
      - id: main_leds
        from: 3
        to: 3


# Physical button on D6 to turn off all LEDs
binary_sensor:
  - platform: gpio
    pin:
      number: D6
      mode: INPUT_PULLUP
      inverted: True
    name: "All Off Button"
    on_press:
      - logger.log: "Button pressed — turning all LEDs OFF"
      - light.turn_off: main_leds

Home Assistant automation:

alias: Afvalwijzer Plastic
description: Afvalwijzer leds
triggers:
  - at: "18:00:00"
    trigger: time
conditions:
  - condition: template
    value_template: >
      {% set raw = states('sensor.gad_pmd') %} {% if raw not in ['unknown',
      'unavailable', 'none', ''] %}
        {% set clean = raw.replace('Tomorrow, ', '').replace('Today, ', '').strip() %}
        {% set parts = clean.split(', ') %}
        {% set date_part = parts[-1] if parts|length > 1 else clean %}
        {% set pmd_date = strptime(date_part, '%d-%m-%Y').date() %}
        {% set tomorrow = (now().date() + timedelta(days=1)) %}
        {{ pmd_date == tomorrow }}
      {% else %}
        false
      {% endif %}
actions:
  - action: light.turn_on
    target:
      entity_id: light.afvalwemos_led_3
    data:
      rgb_color:
        - 236
        - 199
        - 14
mode: single

Easy cheap touch light

Remember those expensive touch lights you can buy?

This is a less than 5 euro version.

Warning : Some tricks I used

  • Using D5 as GND (I didn’t want to splice GND Wire.)
  • Using PWM for dimming
  • Using 5V led, use resistor if using a generic LED (220ohm)

TTP223 sensors are only a few cents!
And react even without touching (< 5mm)
So, you can build this in a case or behind fabric!

NOTE: If you are using an ESP32 you can configure a pin as touch!!!
So no TTP223 needed.
But ESP32 are more expensive as Wemos mini.

Code:

#define TOUCH_PIN D2 // My video has D7
#define LED_PIN   D6
#define FAKE_GND D5

// Brightness steps (0 = off, 255 = full bright)
int brightnessLevels[] = {0, 25, 125, 255};
int currentLevel = 0;
bool lastTouchState = LOW;

void setup() {
  
  pinMode(TOUCH_PIN, INPUT);
  pinMode(LED_PIN, OUTPUT);
  pinMode(FAKE_GND, OUTPUT);

  digitalWrite(FAKE_GND, LOW);  
  analogWrite(LED_PIN, brightnessLevels[currentLevel]);  // start OFF
}

void loop() {
  bool touchState = digitalRead(TOUCH_PIN);

    if (touchState == HIGH && lastTouchState == LOW) {
      // Advance brightness step
      currentLevel = (currentLevel + 1) % 4;  // 0-3 steps
      int pwmValue = brightnessLevels[currentLevel];
      analogWrite(LED_PIN, pwmValue);
    }

  lastTouchState = touchState;
}

Dim levels less obvious on recording, but you can change the levels in the code!

DIY Garden Lights.

We are planning to redo our garden. And I am making a water and light plan for it.

I thought I could do it myself using 12V and RS485/Modbus.

So these are my plans. (NOTE, this is a work in progress)

I’m going to put 4-wire ground cable in our garden, and a RS485/Modbus master controller in my shed.
4 Wires will have 12V low voltage, ground and RS485 A/B wires.
This way I can control till 64 devices on a single cable.

Below, a USB stick to connect the RS485 cables to a Raspberry Pi?
Software is probably going to be a NodeRed instance connected to Home Assistant.

On/Off lights using a RS485 board and relay. These can be bought on a single PCB and can control 220V. I am probably going to use generic outside lamps and refit them for 12V led or 220v, with those RS485 controllers.

The above left part will be encased in resin or alike.
Right PCB is for testing only.

For dimming RGB lights, I made the below design.

NOTE: This needs 120ohm end resistor and capacitors over the 7805.

12V to 5V using a 7805, RS485 8pin DIL/DIP and a ATTiny85 8pin DIL/DIP. Plus a 4×4 RGB Matrix.
These also encased in resin.

More information on the ATTiny85 and programmer can be found here:

Modbus using NodeRed (I’ve used this to control my RD6006 Lab Power Supply)

Bare minimal to control the relay.

HA control via MQTT

5 sensor Flame detector

December is coming, time for candles.

The plan is to make a flame sensor, with in combination with my presence sensor will alert me when we leave the room and candles are on, give us a notification.

Above is the schematic. A Wemos mini (left over from another project), an analog multiplexer and a cheap 5 times flame detector.
There is a potentiometer on this board to change the sensitivity.

Search “Infrared Ir Flame Sensor Detector Fire Detection Module 5 Channel” on Aliexpress. These are 1 euro.

Presence sensor:

Here is some Arduino code.

#define MUX_A D5
#define MUX_B D6
#define MUX_C D7
#define ENABLE D1

#define ANALOG_INPUT A0


void setup() {
    Serial.begin(9600);

  //Define output pins for Mux
  pinMode(MUX_A, OUTPUT);
  pinMode(MUX_B, OUTPUT);     
  pinMode(MUX_C, OUTPUT);     
  pinMode(ENABLE, OUTPUT);     
  digitalWrite(ENABLE, LOW); // No need to switch, can be permanenty low
  
}

void changeMux(int c, int b, int a) {
  digitalWrite(MUX_A, a);
  digitalWrite(MUX_B, b);
  digitalWrite(MUX_C, c);
}

void loop() {
  float value;
  
  
  changeMux(LOW, LOW, HIGH);
  value = analogRead(ANALOG_INPUT); //Value of the sensor connected Option 1 pin of Mux
  Serial.print("Variable_1:");
  Serial.print(value);
  Serial.print(",");

  changeMux(LOW, HIGH, LOW);
  value = analogRead(ANALOG_INPUT); //Value of the sensor connected Option 2 pin of Mux
  Serial.print("Variable_2:");
  Serial.print(value);
  Serial.print(",");

  changeMux(LOW, HIGH, HIGH);
  value = analogRead(ANALOG_INPUT); //Value of the sensor connected Option 3 pin of Mux
  Serial.print("Variable_3:");
  Serial.print(value);
  Serial.print(",");

  changeMux(HIGH, LOW, LOW);
  value = analogRead(ANALOG_INPUT); //Value of the sensor connected Option 4 pin of Mux
  Serial.print("Variable_4:");
  Serial.print(value);
  Serial.print(",");

  changeMux(HIGH, LOW, HIGH);
  value = analogRead(ANALOG_INPUT); //Value of the sensor connected Option 5 pin of Mux
  Serial.print("Variable_5:");
  Serial.println(value);
}

ESPHOME Code

esphome:
  name: flamedetector
  friendly_name: FlameDetector
  on_boot:
    priority: 600
    then:
      - switch.turn_off: enable 

esp8266:
  board: d1_mini

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "BMHbLdN6Rxxxxxxxxxxxxxxxxxxxxv0LE="

ota:
  - platform: esphome
    password: "780fe0xxxxxxxxxxxxxxxxxxxxx7859"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Flamedetector Fallback Hotspot"
    password: "cm1xxxxxxxxxxxxNX"

captive_portal:

switch:
  - platform: gpio
    id: enable
    pin:
      number: D1

cd74hc4067:
  - id: cd74hc4067_1
    pin_s0: D5
    pin_s1: D6
    pin_s2: D7
    pin_s3: D2

globals:
  - id: last_flame_state
    type: bool
    restore_value: no
    initial_value: 'false'
  - id: trigger_on
    type: bool
    restore_value: no
    initial_value: 'false'

sensor:
  - platform: adc
    id: adc_sensor
    pin: A0
    name: flameanalog
    update_interval: 10s
  - platform: cd74hc4067
    id: flame_1_1
    name: flame_1_1
    number: 1
    sensor: adc_sensor
    update_interval: 10s
  - platform: cd74hc4067
    id: flame_1_2
    name: flame_1_2
    number: 2
    sensor: adc_sensor
    update_interval: 10s
  - platform: cd74hc4067
    id: flame_1_3
    name: flame_1_3
    number: 3
    sensor: adc_sensor
    update_interval: 10s
  - platform: cd74hc4067
    id: flame_1_4
    name: flame_1_4
    number: 4
    sensor: adc_sensor
    update_interval: 10s
  - platform: cd74hc4067
    id: flame_1_5
    name: flame_1_5
    number: 5
    sensor: adc_sensor
    update_interval: 10s


interval:
  - interval: 10s
    then:
      - lambda: |-
          const float threshold = 0.01;
          bool any_above =
            id(flame_1_1).state > threshold ||
            id(flame_1_2).state > threshold ||
            id(flame_1_3).state > threshold ||
            id(flame_1_4).state > threshold ||
            id(flame_1_5).state > threshold;

          // Store the result
          if (any_above != id(last_flame_state)) {
            id(last_flame_state) = any_above;
            if (any_above) {
              ESP_LOGI("flame_check", "Flame detected (turning ON)");
              id(trigger_on) = true;
            } else {
              ESP_LOGI("flame_check", "No flame detected (turning OFF)");
              id(trigger_on) = false;
            }
          }

  - interval: 1s
    then:
      - if:
          condition:
            lambda: 'return id(trigger_on);'
          then:
            - homeassistant.service:
                service: homeassistant.turn_on
                data:
                  entity_id: input_boolean.testtoggle
          else:
            - homeassistant.service:
                service: homeassistant.turn_off
                data:
                  entity_id: input_boolean.testtoggle

ESPHome HA notification helper

A while ago I made a little notify thingy using an LCD display, LED, button and a buzzer.

Some friends of mine made one also, and today I was talking to a new guy.
I could not find this project on my site .. again .. so I’ll post it now.

Some things it does right now

  • Front door opens
  • Door bell rings (because my lab is in the garden)
  • Girlfriend is 10Km away, so let’s start cooking
  • Garage door opens (friend of mine uses this to know when kids arrive)
  • More .. because it’s very easy to customize in Home Assistant

Doorbell pressed: Led starts blinking, backlight LCD enabled, text displayed on LCD, Buzzer sounds (or plays a RTTTL ringtone)
LCD backlight on and buzzer beep until acknowledge button pressed.

Heating for brewing: temperature on display, led on when temperature reached. Press acknowledge to start timer.

….. etc

CODE:

esphome:
  name: lcdalarm
  friendly_name: LCDAlarm
  on_boot:
    priority: -100.0
    then:
      - lambda: 'id(lcddisplay).no_backlight();'

esp8266:
  board: d1_mini

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "OOpDzMu0VvfWAnJXXXXXXXXXXXXXXXXXXXXXXXXKNDa74OJ1Cc="

ota:
   - platform: esphome
     password: "627992017XXXXXXXXXXXXXXX738c2a3"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Lcdalarm Fallback Hotspot"
    password: "z9dZXXXXXXXX7a"

captive_portal:
web_server:
  port: 80

i2c:
  sda: D2
  scl: D1
  scan: true
  frequency: 400kHz

light:
  - platform: binary
    name: "Status LED"
    output: light_output
    icon: "mdi:led-on"
    id: led


binary_sensor:
  - platform: gpio
    pin:
      inverted: true
      number: D5
      mode:
        input: true
        pullup: true
    name: "LCD Button"

text_sensor:
  # Create text input helper in homeassistant and enter id below
  - platform: homeassistant
    entity_id: input_text.lcd_display_text
    id: lcd_display_text

  - platform: wifi_info
    ip_address:
      id: ip_address
      name: IP address
    mac_address:
      id: mac_address
      name: Mac address
    ssid:
      id: connected_ssid
      name: Network SSID
    bssid:
      id: connected_
      name: Network Mac

sensor:
  - platform: wifi_signal
    id: wifisignal
    name: wifi-signal
    update_interval: 300s

globals:
  - id: backlight_on
    type: bool
    initial_value: 'false'
  - id: flash_beep_on
    type: bool
    initial_value: 'false'

switch:
  - platform: template
    name: "LCD Backlight"
    id: backlight
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - globals.set:
          id: backlight_on
          value: 'true'
      - globals.set:
          id: flash_beep_on
          value: 'false'
      - lambda: |-
          id(lcddisplay).backlight();
      - delay: 5s
    turn_off_action:
      - globals.set:
          id: backlight_on
          value: 'false'
      - lambda: |-
            id(lcddisplay).no_backlight();
    lambda: |-
      return id(backlight_on);

  - platform: template
    name: "Flash and Beep"
    id: flash_beep
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - globals.set:
          id: flash_beep_on
          value: 'true'
      - globals.set:
          id: backlight_on
          value: 'false'
      - lambda: |-
          id(lcddisplay).backlight();
      - lambda: |-
          id(buzzer).turn_on();
      - delay: 0.75s
      - lambda: |-
          id(lcddisplay).no_backlight();
      - lambda: |-
          id(buzzer).turn_off();
      - delay: 0.75s
      - lambda: |-
          id(lcddisplay).backlight();
      - lambda: |-
          id(buzzer).turn_on();
      - delay: 0.75s
      - lambda: |-
          id(lcddisplay).no_backlight();
      - lambda: |-
          id(buzzer).turn_off();
      - delay: 0.75s
      - lambda: |-
          id(lcddisplay).backlight();
      - lambda: |-
          id(buzzer).turn_on();
      - delay: 0.75s
      - lambda: |-
          id(lcddisplay).no_backlight();
      - lambda: |-
          id(buzzer).turn_off();
      - delay: 0.75s
      - lambda: |-
          id(lcddisplay).backlight();
    turn_off_action:
      - globals.set:
          id: flash_beep_on
          value: 'false'
      - lambda: |-
            id(lcddisplay).no_backlight();
      - lambda: |-
          id(buzzer).turn_off();
    lambda: |-
      return id(flash_beep_on);


  - platform: restart
    name: "Restart ESPhome"

output:
  - platform: gpio
    pin: D3
    id: buzzer
  - platform: gpio
    pin: D6
    id: light_output

display:
  - platform: lcd_pcf8574
    id: lcddisplay
    dimensions: 16x2
    address: 0x27
    lambda: |-
      if(id(wifisignal).has_state()) {
        if (id(lcd_display_text).state != "") {
          it.print(0, 0, id(lcd_display_text).state.c_str());
        }
      } else {
        it.print("    ESPhome        booting...   ");
      }

CODE: RTTTL (Use passive buzzer!!!!)

esphome:
  name: lablcd
  friendly_name: LabLCD

esp8266:
  board: d1_mini

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "ga17mNCtxdiXXXXXXXXXXXXXXXXXXXXX70a5W0VPZR51Q="
  actions:
    - action: rtttl_play
      variables:
        song_str: string
      then:
        - rtttl.play:
            rtttl: !lambda 'return song_str;'

ota:
  - platform: esphome
    password: "84013b9XXXXXXXXXXXXXXX5401039f7c"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Lablcd Fallback Hotspot"
    password: "nnzXXXXXXXXXXXXXXXXXXXCb"

rtttl:
  output: rtttl_out
  on_finished_playback:
    - logger.log: 'Song ended!'


captive_portal:

i2c:
  sda: D2
  scl: D1
  scan: true
  frequency: 400kHz

light:
  - platform: status_led
    name: "Status LED"
    icon: "mdi:led-on"
    pin:
      number: D5
      inverted: true

binary_sensor:
  - platform: gpio
    name: "Push Button"
    pin: 
      number: D4
      inverted: true
      mode: 
        input: true
        pullup: true
    on_press:
      - homeassistant.service:
          service: homeassistant.toggle
          data:
            entity_id: input_boolean.esphome_notification_lcdlab_push_button

text_sensor:
  # Create text input helper in homeassistant and enter id below
  - platform: homeassistant
    entity_id: input_text.esphome_notification_lcdlcd_display_text
    id: lcd_display_text

  - platform: wifi_info
    ip_address:
      id: ip_address
      name: IP address
    mac_address:
      id: mac_address
      name: Mac address
    ssid:
      id: connected_ssid
      name: Network SSID
    bssid:
      id: connected_
      name: Network Mac

sensor:
  - platform: wifi_signal
    id: wifisignal
    name: wifi-signal
    update_interval: 300s

globals:
  - id: backlight_on
    type: bool
    initial_value: 'false'
  - id: flash_beep_on
    type: bool
    initial_value: 'false'

switch:
  - platform: template
    name: "LCD Backlight Lab"
    id: backlight
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - globals.set:
          id: backlight_on
          value: 'true'
      - globals.set:
          id: flash_beep_on
          value: 'false'
      - lambda: |-
          id(lcddisplay).backlight();
    turn_off_action:
      - globals.set:
          id: backlight_on
          value: 'false'
      - lambda: |-
            id(lcddisplay).no_backlight();
    lambda: |-
      return id(backlight_on);

 
  - platform: restart
    name: "Restart ESPhome"

output:
  - platform: esp8266_pwm
    pin: D8
    id: rtttl_out

display:
  - platform: lcd_pcf8574
    id: lcddisplay
    dimensions: 16x2
    address: 0x27
    lambda: |-
      if(id(wifisignal).has_state()) {
        if (id(lcd_display_text).state != "") {
          it.print(0, 0, id(lcd_display_text).state.c_str());
        }
      } else {
        it.print("    ESPhome        booting...   ");
      }


Mac Address scanner for my home.

I bought 4x Xiao-S3 mini controllers. I want to place these all over my house to scan for Bluetooth and Wifi Clients. So I can do a location search for Mobile Phones, Keys and more.

Also the Bluetooth tags I used for the Scanner Game can be used!

I want to post a location to Home Assistant, But I also played with 3D view.

Using MQTT I can subscribe to the topic locator/scanner1/ble or locator/scanner1/wifi_clients

Problems I ran into.

Too many duplicates, fixed.
Can not scan Wifi when connected, so I connect every 30s.
Could not find all wifi clients, needed to scan all channels!

CODE

#include <WiFi.h>
#include <PubSubClient.h>
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include "esp_wifi.h"

const char* mqtt_server = "MQTTSERVER";   // change to your broker
const int   mqtt_port   = 1883;
const char* mqtt_user   = "";
const char* mqtt_pass   = "";

const char* ssid     = "MYWIFI";
const char* password = "MYWIFIPASSWD";

WiFiClient espClient;
PubSubClient client(espClient);

#define MAX_BUFFER 200

struct DeviceRecord {
  String type;   // "wifi_client" or "ble"
  String mac;
  int rssi;
};

DeviceRecord buffer[MAX_BUFFER];
int bufferCount = 0;

// ====== BLE ======
BLEScan* pBLEScan;
const int bleScanTime = 3; // seconds

typedef struct {
  unsigned frame_ctrl:16;
  unsigned duration_id:16;
  uint8_t addr1[6];
  uint8_t addr2[6];
  uint8_t addr3[6];
  uint16_t sequence_ctrl;
  uint8_t addr4[6];
} wifi_ieee80211_mac_hdr_t;

typedef struct {
  wifi_ieee80211_mac_hdr_t hdr;
  uint8_t payload[0];
} wifi_ieee80211_packet_t;

void formatMAC(const uint8_t *addr, char *buf) {
  sprintf(buf, "%02X:%02X:%02X:%02X:%02X:%02X",
          addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]);
}

// ====== Dedup ======
void addToBuffer(String type, String mac, int rssi) {
  for (int i = 0; i < bufferCount; i++) {
    if (buffer[i].mac == mac && buffer[i].type == type) {
      buffer[i].rssi = rssi;  // update latest RSSI
      return;
    }
  }
  if (bufferCount < MAX_BUFFER) {
    buffer[bufferCount].type = type;
    buffer[bufferCount].mac = mac;
    buffer[bufferCount].rssi = rssi;
    bufferCount++;
  }
}

// ====== Sniffer ======
void wifi_sniffer_packet_handler(void* buf, wifi_promiscuous_pkt_type_t type) {
  if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return;

  const wifi_promiscuous_pkt_t *ppkt = (wifi_promiscuous_pkt_t *)buf;
  const wifi_ieee80211_packet_t *ipkt = (wifi_ieee80211_packet_t *)ppkt->payload;
  const wifi_ieee80211_mac_hdr_t *hdr = &ipkt->hdr;

  char mac[18];
  formatMAC(hdr->addr2, mac);
  int rssi = ppkt->rx_ctrl.rssi;

  addToBuffer("wifi_client", String(mac), rssi);
}

// ====== BLE CALLBACK ======
class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    String mac  = advertisedDevice.getAddress().toString().c_str();
    int rssi    = advertisedDevice.getRSSI();
    addToBuffer("ble", mac, rssi);
  }
};

void startSniffer() {
  WiFi.mode(WIFI_STA);        // keep STA mode
  WiFi.disconnect();          // ensure not connected

  wifi_promiscuous_filter_t filter = {
    .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA
  };
  esp_wifi_set_promiscuous_filter(&filter);
  esp_wifi_set_promiscuous_rx_cb(&wifi_sniffer_packet_handler);
  esp_wifi_set_promiscuous(true);

  esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE);
  Serial.println("Sniffer ON (starting on channel 1)");
}

void stopSniffer() {
  esp_wifi_set_promiscuous(false);
  Serial.println("Sniffer OFF");
}

void publishBuffer() {
  Serial.println("Connecting to WiFi for MQTT...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  unsigned long startAttempt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startAttempt < 10000) {
    delay(200);
    Serial.print(".");
  }
  Serial.println(WiFi.isConnected() ? "\nWiFi connected" : "\nWiFi connect failed");

  if (WiFi.isConnected()) {
    client.setServer(mqtt_server, mqtt_port);
    if (client.connect("ESP32Scanner4", mqtt_user, mqtt_pass)) {
      Serial.println("MQTT connected");
      for (int i = 0; i < bufferCount; i++) {
        String payload = "{";
        payload += "\"type\":\"" + buffer[i].type + "\",";
        payload += "\"mac\":\"" + buffer[i].mac + "\",";
        payload += "\"rssi\":" + String(buffer[i].rssi);
        payload += "}";
        if (buffer[i].type == "ble")
          client.publish("locator/scanner4/ble", payload.c_str());
        else
          client.publish("locator/scanner4/wifi_clients", payload.c_str());
        delay(5);
      }
    } else {
      Serial.println("MQTT connect failed");
    }
  }
  client.disconnect();
  WiFi.disconnect(true, true);
  bufferCount = 0; // clear buffer
  Serial.println("Published & WiFi disconnected");
}

// ====== Setup ======
void setup() {
  Serial.begin(115200);

  // BLE init
  BLEDevice::init("");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setActiveScan(true);
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());

  startSniffer();
}

unsigned long lastPublish = 0;
const unsigned long publishInterval = 30000; // 30s
unsigned long lastChannelHop = 0;
uint8_t currentChannel = 1;

void loop() {
  // BLE scan while sniffing
  pBLEScan->start(bleScanTime, false);
  pBLEScan->clearResults();

  // Channel hopping !
  if (millis() - lastChannelHop > 500) {
    lastChannelHop = millis();
    currentChannel++;
    if (currentChannel > 13) currentChannel = 1;
    esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE);
    // Serial.printf("Hopped to channel %d\n", currentChannel);
  }

  // Every 30s
  if (millis() - lastPublish > publishInterval) {
    lastPublish = millis();
    stopSniffer();
    publishBuffer();
    startSniffer();
  }
}