Home Office status ("stoplight") indicator

Ever since I’ve been working from home, I’ve wanted an indicator light outside my office door to let my wife and kids know whether they can bother me:

  • Green: Come in
  • Yellow: In a meeting, but on mute. Come in if it’s urgent but be quick.
  • Red: In a meeting; either not on mute or webcam is active. Don’t bother me.

My requirements were that it needed to be mains powered (I didn’t want to change batteries), it needed to be locally operated (I avoid cloud devices where possible), and it needed to be automated. In order to automate it, I had to have something running on my work laptop to monitor meeting, mute, and camera status. Unfortunately my work laptop is often connected through a VPN and I also don’t have admin access to install programs. Thus began a many-months-long process to implement a solution.

Status Light: I spent a lot of time looking for an indicator, and I first came across an Aqara M1 hub which looked nice but I wasn’t sure about ignoring/disabling all the features to use only the light functionality. Right before I pulled the trigger, I happened across the Globe Electric Smart Ambient light which was a bit cheaper and had only the functionality I desired. Once I received it, I tried to flash local-Tuya but discovered it wasn’t compatible. (2023 edit: this is now supported and you can flash it with ESPHome without opening it up. See Digiblur’s walkthrough.) So as any normal person would do, instead of opting for cloud control, I took a hammer and prybar to it.


Turns out it had a WB3S chip which is pin-for-pin compatible with an ESP12, so that’s the road I went down. Kid’s play-doh worked out well as a heat shield / heat sink for the heat gun.

Here’s my awful soldering job, and with the 10k pull-up for the enable pin (on the top of the chip), and 10k pull-down to GPIO15 (on the bottom side):

Below is a simplified version of the ESPhome code I used; this enables the basic functionality of the light, motion sensor, and button. This light is actually a RGBWW light where you can change the white color temperature because there are both warm and cool white LEDs, but mine had a failed transistor for the green color circuit so my light had the green LEDs illuminated all the time. I think I must have ruined it somehow during my ESP12 installation. Anyway, to fix it, I took the transistor from the warm LED circuit. So now my warm LEDs are non-functional. And that’s why my code doesn’t utilize the warm LED output. If someone replicates this, you can use a RGBWW platform in the code below instead.

substitutions:
  # Device Naming
  devicename: office-status-light
  friendly_name: Office Status Light
  device_description:  Status LED, Night Light, Motion Detector

esphome:
  name: $devicename
  comment: ${device_description}

esp8266:
  board: esp01_1m

# Enable logging
logger:

# Enable Home Assistant API
api:

ota:
  password: "some_complicated_password"

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

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: $devicename
    password: "some_other_complicated_password"

captive_portal:

web_server:
  port: 80

light:
  - platform: rgbw
    name: ${friendly_name}
    id: rgbw_led
    red: red_output
    green: green_output
    blue: blue_output
    white: cool_output
    restore_mode: ALWAYS_OFF
    color_interlock: true
    gamma_correct: 1.0
output:
  - platform: esp8266_pwm
    id: red_output
    pin: GPIO4
    max_power: 100%
  - platform: esp8266_pwm
    id: green_output
    pin: GPIO12
    max_power: 15%
  - platform: esp8266_pwm
    id: blue_output
    pin: GPIO14
    max_power: 15%
  - platform: esp8266_pwm
    id: warm_output
    pin: GPIO13
  - platform: esp8266_pwm
    id: cool_output
    pin: GPIO5

binary_sensor:
  - platform: gpio
    name: ${friendly_name} Motion
    pin: 
      number: GPIO16
      mode:
        input: true
  - platform: gpio
    name: ${friendly_name} Button
    pin: 
      number: GPIO0
      mode:
        input: true
      inverted: true

Laptop Script:
Once I got that working, the next step was the code running on my laptop to push meeting/mute/camera status to Home Assistant. This was where I ran into most of my troubles. I won’t detail them in order to save you from boredom. Suffice to say that I made an autohotkey script to POST to HA RestAPI, then changed to a python script to push out to MQTT, then changed to a python script to POST to webhook, then changed to a python script to push to an AWS Lambda function which relays the request to a HA webhook. For most people, any of these methods would work. For my locked-down work laptop, I had to send out to the AWS Lambda function because my own HA instance was blocked by my work firewall. (I even bought a domain and that was blocked shortly thereafter.)

Here’s the simplified version of code that I ended up with:
Python GIST
This code uses a different URL for POST’ing the data to AWS after each POST attempt. I found I had better success with HTTP errors, and I don’t know if that’s due to the work firewall or what. This code can be easily modified to POST directly to a homeassistant webhook, and if anyone uses it I suspect that’s what you’ll want to do. So I’m not going to share the AWS Lambda function code or explain how I set that up.

Home Assistant Automation Webhook:
For the webhook, I set up 3 helper toggles: one for the status of each ‘thing’ (meeting, mute, camera). Then I created this automation, which takes the webhook data and updates each helper toggle:

alias: Laptop Webhook
description: ''
trigger:
  - platform: webhook
    webhook_id: mylaptop-webhook-some-unguessable-random-code-here
condition: []
action:
  - if:
      - condition: template
        value_template: >-
          {{ states('input_boolean.mylaptop_mute_status') !=
          trigger.json.mute_status }}
    then:
      - service: input_boolean.toggle
        data: {}
        target:
          entity_id: input_boolean.mylaptop_mute_status
  - if:
      - condition: template
        value_template: >-
          {{ states('input_boolean.mylaptop_meeting_status') !=
          trigger.json.meeting_status }}
    then:
      - service: input_boolean.toggle
        data: {}
        target:
          entity_id: input_boolean.mylaptop_meeting_status
  - if:
      - condition: template
        value_template: >-
          {{ states('input_boolean.mylaptop_camera_status') !=
          trigger.json.camera_status }}
    then:
      - service: input_boolean.toggle
        data: {}
        target:
          entity_id: input_boolean.mylaptop_camera_status
mode: restart

Home Assistant Automation for Indicator Color:
Then, all that was left was to create an automation to pick the correct color based on those status toggles. I also added a toggle for a night light; so I could turn that toggle on whenever I want the night light to be on (but it won’t override the status light color, in case I’m working late).

alias: Office Status Light
description: Change the color of the office status light based on helper toggle status
trigger:
  - platform: state
    entity_id:
      - input_boolean.mylaptop_meeting_status
      - input_boolean.mylaptop_camera_status
      - input_boolean.mylaptop_mute_status
      - input_boolean.hallway_nightlight
      - device_tracker.mylaptop_ethernet
      - device_tracker.mylaptop_wifi
  - platform: time
    at: '07:00:00'
  - platform: time
    at: '17:30:00'
condition:
  - condition: or
    conditions:
      - condition: not
        conditions:
          - condition: state
            entity_id: device_tracker.mylaptop_wifi
            state: not_home
      - condition: not
        conditions:
          - condition: state
            entity_id: device_tracker.mylaptop_ethernet
            state: not_home
action:
  - choose:
      - conditions:
          - condition: state
            entity_id: input_boolean.mylaptop_camera_status
            state: 'on'
        sequence:
          - service: light.turn_on
            data:
              rgb_color:
                - 255
                - 0
                - 0
              brightness_pct: 100
            target:
              entity_id: light.office_status_light
      - conditions:
          - condition: and
            conditions:
              - condition: state
                entity_id: input_boolean.mylaptop_meeting_status
                state: 'on'
              - condition: state
                entity_id: input_boolean.mylaptop_mute_status
                state: 'off'
        sequence:
          - service: light.turn_on
            data:
              rgb_color:
                - 255
                - 0
                - 0
              brightness_pct: 100
            target:
              entity_id: light.office_status_light
      - conditions:
          - condition: and
            conditions:
              - condition: state
                entity_id: input_boolean.mylaptop_meeting_status
                state: 'on'
              - condition: state
                entity_id: input_boolean.mylaptop_mute_status
                state: 'on'
        sequence:
          - service: light.turn_on
            data:
              rgb_color:
                - 255
                - 255
                - 0
              brightness_pct: 100
            target:
              entity_id: light.office_status_light
      - conditions:
          - condition: and
            conditions:
              - condition: state
                entity_id: input_boolean.mylaptop_camera_status
                state: 'off'
              - condition: state
                entity_id: input_boolean.mylaptop_meeting_status
                state: 'off'
              - condition: time
                before: '17:30:00'
                after: '06:00:00'
                weekday:
                  - sun
                  - sat
                  - fri
                  - thu
                  - wed
                  - tue
                  - mon
              - condition: state
                entity_id: binary_sensor.workday
                state: 'on'
        sequence:
          - service: light.turn_on
            data:
              rgb_color:
                - 0
                - 255
                - 0
              brightness_pct: 100
            target:
              entity_id: light.office_status_light
      - conditions:
          - condition: and
            conditions:
              - condition: state
                entity_id: input_boolean.mylaptop_camera_status
                state: 'off'
              - condition: state
                entity_id: input_boolean.mylaptop_meeting_status
                state: 'off'
              - condition: state
                entity_id: input_boolean.hallway_nightlight
                state: 'on'
        sequence:
          - service: light.turn_on
            data:
              white: 255
            target:
              entity_id: light.office_status_light
      - conditions:
          - condition: and
            conditions:
              - condition: state
                entity_id: input_boolean.mylaptop_camera_status
                state: 'off'
              - condition: state
                entity_id: input_boolean.mylaptop_meeting_status
                state: 'off'
              - condition: state
                entity_id: input_boolean.hallway_nightlight
                state: 'off'
        sequence:
          - service: light.turn_off
            target:
              entity_id: light.office_status_light
            data: {}
    default: []
mode: restart

Final result: Don’t Enter!

5 Likes

:grinning_face_with_smiling_eyes: How did that smell?

1 Like

:rofl:
When it started turning black I braced myself for something putrid, but it turned out not bad at all. Almost pleasant in fact. Like I was cooking/burning flour.

1 Like