본문 바로가기

홈어시스턴트 IoT

미세먼지와 이산화탄소에 진심? Waveshare e-ink에 항상 표시

실내 미세먼지의 관리 여부와 외부 마스크 착용 여부에 따라 '코'나 '목'의 (콧물, 가래나 불편함)상태가 바뀌는 것을 체감한 후부터는 항상 실내 이산화탄소와 미세먼지 수치를 관리하고 있습니다. 특히 실내 초미세와 미세먼지 수치는 5㎍/㎥ 미만으로 365일 24시간 관리하고 있습니다. 

 

거실에 놓아둔 라즈베리파이 7인치 모니터로 Home Assistant가 표시되는 부모님 댁과 달리, 저는 컴퓨터나 스마트폰으로만 확인이 가능했는데, 잉여로 남아 돌던 e-ink 2.7인치에 표시해 보았습니다. 

 

간단하지만 좋네요. 

 

실외/실내 초미세먼지, 이산화탄소

 

내용도 추가하고 한글 출력 및 3D 인쇄한 케이스도 부착해 보았습니다. 

 

앞쪽 케이스 추가한 모습

 

참고로 3D 케이스는 아직도(!) 설계 툴을 배우지 않고 ChatGPT를 괴롭혔습니다. OpenSCAD를 사용하여 .SCAD로부터 .SLT를 만들 수 있었습니다. 인쇄 중에 문제가 있었긴 했는데, 나중에 칼로 잘 잘라내고 긁어냈습니다. 아무래도 인쇄 재료 아끼는 것 때문에 문제가 있었던 것 같고 빼는 것이 나을 것 같습니다. 

# 내부가 비어 있는 직육면체가 필요합니다.  
# 직육면체의 외곽 크기는 84x58x8 (단위: mm) 이고, 윗면 케이스만 있으면 되고, 아랫면은 비워주세요. 
# 전체적인 케이스의 두께는 6mm이고, 특별히 LCD를 결합할 윗면은 2mm로 해주세요. 
# LCD viewing 영역은 뚫어 주어야 하는데, 윗면 기준으로 좌표는 mm단위로 좌상단 (17, 10)과 우하단 (74, 48)입니다. 
# 3D 인쇄 재료를 아낄 수 있게 해주고, 모서리 r도 적당히 추가해 주세요.

from solid import *
from solid.utils import *

# Dimensions in mm
outer_dimensions = [84, 58, 8]  # Length, width, height
wall_thickness = 3  # Reduced to save material
lcd_cover_thickness = 2
lcd_viewing_area_top_left = [17, 10]
lcd_viewing_area_bottom_right = [74, 48]
corner_radius = 2  # Small rounding for edges

# Create the outer box with rounded corners
def create_case():
    outer_box = rounded_cube(outer_dimensions, corner_radius)

    # Subtract the inner box to create the hollow structure
    inner_dimensions = [
        outer_dimensions[0] - 2 * wall_thickness,
        outer_dimensions[1] - 2 * wall_thickness,
        outer_dimensions[2]
    ]

    inner_box = translate([wall_thickness, wall_thickness, 0])(rounded_cube(inner_dimensions, corner_radius))
    hollow_case = outer_box - inner_box

    # Create the LCD cover part (2mm thick)
    lcd_cover = cube([
        outer_dimensions[0],
        outer_dimensions[1],
        lcd_cover_thickness
    ], center=False)

    # Add the viewing area cutout
    lcd_viewing_area = translate([
        lcd_viewing_area_top_left[0],
        lcd_viewing_area_top_left[1],
        0
    ])(cube([
        lcd_viewing_area_bottom_right[0] - lcd_viewing_area_top_left[0],
        lcd_viewing_area_bottom_right[1] - lcd_viewing_area_top_left[1],
        lcd_cover_thickness
    ], center=False))

    lcd_cover_with_cutout = lcd_cover - lcd_viewing_area

    # Combine the hollow case and the LCD cover
    final_case = hollow_case + translate([0, 0, outer_dimensions[2] - lcd_cover_thickness])(lcd_cover_with_cutout)

    return final_case

# Rounded cube function
def rounded_cube(size, radius):
    return minkowski()(cube(size), sphere(radius))

# Generate the 3D model
case_model = create_case()

# Save the model to an STL file
scad_render_to_file(case_model, 'rectangular_case.scad')

 

[사용 장치] 

  • 2.7인치 e-ink 디스플레이 : 원래 RPi용 HAT에 붙어 있던 것을 떼어 내어 이용(제품 설명)
  • Waveshare e-Paper ESP8266 Driver Board(제품 설명)

[ESPHome 코드] 

Home Assistant에 HACS를 통해 설치한 네이버 날씨 정보에서 외부 초미세먼지를 가져오고, ESP8266보드 기반 DIY로 만든 미세먼지센서(SDS021)와 이산화탄소센서(MH-Z19B)는 ESPHome을 통해 Home Assitant로 연결되어 있습니다. 

 

ESP8266 Driver Board에 올린 ESPHome .YAML 파일 내용은 다음과 같습니다. 

esphome:
  name: epaper8266

esp8266:
  board: nodemcu

# Enable logging
logger:

# Enable Home Assistant API
api:
  password: ""

ota:
  - platform: esphome
    password: ""

wifi:
  ssid: !secret ssid 
  password: !secret password 
#  domain: !secret domain 
  
  manual_ip:
    # Set this to the IP of the ESP
    static_ip: 192.168.1.177
    # Set this to the IP address of the router. Often ends with .1
    gateway: 192.168.1.1
    # The subnet of the network. 255.255.255.0 works for most home networks.
    subnet: 255.255.255.0

captive_portal:

time:
  - platform: homeassistant
    id: ntp

# fonts directory and ttf file are required 
font:
  - file: "NanumBarunGothic-YetHangul.ttf"
    id: font1
    size: 24
    glyphs: |-
      !"%()+=,-_./:°℃℉✽㎍[]0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz초미세먼지외부내이산화탄소
      현재날씨정보온도오늘일최고저풍속강수확률습구름많음비안옴흐림눈나기시월분작후맑전잠만다리요실공질예가끔황

spi:
  clk_pin: D5
  mosi_pin: D7

# Your sensor values of target Home Assistant 
sensor: 
  - platform: homeassistant 
    entity_id: sensor.2_5um
    id: indoor_pm25
    internal: true 
  - platform: homeassistant 
    entity_id: sensor.naver_weather_ultrafinedust_1
    id: outdoor_pm25
    internal: true 
  - platform: homeassistant 
    entity_id: sensor.mh_z19_co2_value
    id: co2
    internal: true 
  - platform: homeassistant 
    entity_id: sensor.mi_air_purifier_2s_humidity
    id: indoor_humidity
    internal: true 
  - platform: homeassistant 
    entity_id: sensor.naver_weather_nowtemp_1             # 현재 온도
    id: nowtemp
    
display:
  - platform: waveshare_epaper
    cs_pin: D8
    dc_pin: D2
    busy_pin: D1
    reset_pin: D4
    model: 2.70in
    rotation: 270
    update_interval: 60s 
    lambda: |-
      // position 
      #define xRes 264
      #define yRes 176 
      #define pad 10
      
      it.strftime(0 + pad, 0 + pad, id(font1),  "%m월%d일(%a) %H:%M", id(ntp).now());
      it.printf(0 + pad, 10 + pad + 24, id(font1), TextAlign::TOP_LEFT, "✽ 외부 PM2.5: %.0f㎍", id(outdoor_pm25).state);
      it.printf(0 + pad, 10 + pad + 24 * 2, id(font1), TextAlign::TOP_LEFT, "✽ 실내 PM2.5: %.0f㎍", id(indoor_pm25).state);
      it.printf(0 + pad, 10 + pad + 24 * 3, id(font1), TextAlign::TOP_LEFT, "✽ 이산화탄소: %.0fppm", id(co2).state);
      it.printf(0 + pad, 10 + pad + 24 * 4, id(font1), TextAlign::TOP_LEFT, "✽ 실내 습도: %.0f%%", id(indoor_humidity).state);
      it.printf(0 + pad, 10 + pad + 24 * 5, id(font1), TextAlign::TOP_LEFT, "✽ 현재 온도: %.0f℃", id(nowtemp).state);

 

 

아래는 7.5인치에 Waveshare ESP32 driver board로 구현한 것입니다. 이케아의 포토프레임에 넣어 보았습니다. 

 

 

 

 

esphome:
  name: esp32-eink75

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:

# Enable Home Assistant API
api:
  password: ""

ota:
  platform: esphome
  password: ""

wifi:
  ssid: !secret ssid 
  password: !secret password 
#  domain: !secret domain 
  
#  manual_ip:
    # Set this to the IP of the ESP
#    static_ip: 192.168.0.219
    # Set this to the IP address of the router. Often ends with .1
#    gateway: 192.168.0.1
    # The subnet of the network. 255.255.255.0 works for most home networks.
#    subnet: 255.255.255.0

  ap:
    ssid: "Esp32-Eink75 Fallback Hotspot"
    password: "vzdz2ed5KqFN"

captive_portal:

time:
  - platform: homeassistant
    id: ntp

# ttf files are required 
font:
  - file: "NanumBarunGothic-YetHangul.ttf"
    id: font1
    size: 32
    glyphs: |-
      !"%()+=,-_./:°℃℉✽㎍[]0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz초미세먼지외부내이산화탄소
      현재날씨정보온도오늘일최고저풍속강수확률습구름많음비안옴흐림눈나기시월분작후맑전잠만다리요실공질예가끔황
  - file: "materialdesignicons-webfont.ttf"
    id: mdi
    size: 40
    glyphs:
      - "\U000F0590" # mdi-weather-cloudy
      - "\U000F0F2F" # mdi-weather-cloudy-alert
      - "\U000F0E6E" # mdi-weather-cloudy-arrow-right
      - "\U000F0591" # mdi-weather-fog
      - "\U000F0592" # mdi-weather-hail
      - "\U000F0F30" # mdi-weather-hazy
      - "\U000F0898" # mdi-weather-hurricane
      - "\U000F0593" # mdi-weather-lightning
      - "\U000F067E" # mdi-weather-lightning-rainy
      - "\U000F0594" # mdi-weather-night
      - "\U000F0F31" # mdi-weather-night-partly-cloudy
      - "\U000F0595" # mdi-weather-partly-cloudy
      - "\U000F0F32" # mdi-weather-partly-lightning
      - "\U000F0F33" # mdi-weather-partly-rainy
      - "\U000F0F34" # mdi-weather-partly-snowy
      - "\U000F0F35" # mdi-weather-partly-snowy-rainy
      - "\U000F0596" # mdi-weather-pouring
      - "\U000F0597" # mdi-weather-rainy
      - "\U000F0598" # mdi-weather-snowy
      - "\U000F0F36" # mdi-weather-snowy-heavy
      - "\U000F067F" # mdi-weather-snowy-rainy
      - "\U000F0599" # mdi-weather-sunny
      - "\U000F0F37" # mdi-weather-sunny-alert
      - "\U000F14E4" # mdi-weather-sunny-off
      - "\U000F059A" # mdi-weather-sunset
      - "\U000F059B" # mdi-weather-sunset-down
      - "\U000F059C" # mdi-weather-sunset-up
      - "\U000F0F38" # mdi-weather-tornado
      - "\U000F059D" # mdi-weather-windy
      - "\U000F059E" # mdi-weather-windy-variant
      - "\U000F17FF" # mdi-sun-wireless-outline
      - "\U000F018C" # mdi-compass-outline
      - "\U000F0D43" # mdi-air-filter
      - "\U000F0D44" # mdi-air-purifier
      - "\U000F1586" # mdi-face-mask
      - "\U000F1587" # mdi-face-mask-outline
      - "\U000F11DC" # mdi-window-open-variant
      - "\U000F13E1" # mdi-umbrella-closed-outline

# Your sensor values of target Home Assistant 
sensor: 
  - platform: homeassistant 
    entity_id: sensor.pm_2_5mm              # 초미세먼지(실내) DIY 센서 
    id: indoor_pm25
    internal: true 
  - platform: homeassistant 
    entity_id: sensor.comisemeonji        # 초미세먼지 
    id: outdoor_pm25
    internal: true 
  - platform: homeassistant 
    entity_id: sensor.mh_z19_co2_value     # 이산화탄소 DIY 센서
    id: co2
    internal: true
  - platform: homeassistant 
    entity_id: sensor.misemeonji               # 미세먼지 
    id: outdoor_pm10
    internal: true
  - platform: homeassistant 
    entity_id: sensor.hyeonjaeondo             # 현재 온도
    id: nowtemp
    internal: true
  - platform: homeassistant 
    entity_id: sensor.coegoondo     # 오늘 최고 온도
    id: todaymaxtemp
    internal: true
  - platform: homeassistant 
    entity_id: sensor.naeilcoegoondo    # 내일최고온도
    id: tomorrowmaxtemp
    internal: true
  - platform: homeassistant 
    entity_id: sensor.naeilcoejeoondo   # 내일최저온도
    id: tomorrowmintemp 
    internal: true

text_sensor: 
  - platform: homeassistant 
    entity_id: sensor.hyeonjaenalssi         # 현재 날씨 
    id: nowweather
    internal: true
  - platform: homeassistant 
    entity_id: sensor.naeilojeonnalssi   # 내일오전날씨 
    id: tomorrowweather1
    internal: true
  - platform: homeassistant 
    entity_id: sensor.naeilohunalssi    # 내일오후날씨
    id: tomorrowweather2
    internal: true
  - platform: homeassistant 
    entity_id: sensor.bisijagsiganoneul             # 오늘 비시작 시간 
    id: todayraintime
    internal: true  

color:
  - id: color_black
    red: 0%
    green: 0%
    blue: 0%
    white: 0%
  - id: color_white
    red: 0%
    green: 0%
    blue: 0%
    white: 100%
  - id: color_red
    red: 100%
    green: 0%
    blue: 0%
    white: 0%

spi:
  clk_pin: GPIO13  
  mosi_pin: GPIO14 

display:
  - platform: waveshare_epaper
    cs_pin: GPIO15  
    dc_pin: GPIO27  
    busy_pin: GPIO25    
    reset_pin: GPIO26   
    model: 7.50in-bv3  # -bwr #7.50in-bv3  
    rotation: 270
    update_interval: 60s 
    lambda: |-
      // https://github.com/Nerdiyde/ESPHomeSnippets
      // Map weather states to MDI characters.
      std::map<std::string, std::string> weather_icon_map
        {
          {"구름많음", "\U000F0590"},
          {"안개", "\U000F0591"},
          {"흐림", "\U000F0595"},
          {"가끔비", "\U000F0F33"},
          {"가끔눈", "\U000F0F34"},
          {"가끔비눈", "\U000F0F35"},
          {"폭우", "\U000F0596"},
          {"비", "\U000F0597"},
          {"눈", "\U000F0598"},
          {"폭설", "\U000F0F36"},
          {"비눈", "\U000F067F"},
          {"맑음", "\U000F0599"},
        };

      std::map<std::string, std::string> etc_icon_map
        {
          {"airfilter", "\U000F0D43"}, 
          {"airpurifier", "\U000F0D44"},
          {"facemask", "\U000F1586"},
          {"facemask_outline", "\U000F1587"},
          {"window_open_variant", "\U000F11DC"}, 
          {"umbrella_closed_outline", "\U000F13E1"},
        };

      // position 
      #define padx 24
      #define pady 72
    
      if(id(nowweather).state != "") {
        it.strftime(padx, pady, id(font1), " %Y-%m-%d  %a  %H: %M", id(ntp).now());

        it.printf(padx, pady + 36 * 2, id(font1), TextAlign::TOP_LEFT, "[ 외부 ]");
        it.printf(padx, pady + 36 * 3, id(font1), TextAlign::TOP_LEFT, "✽ 미세먼지: %.0f㎍", id(outdoor_pm10).state);
        if(id(outdoor_pm10).state > 15) { 
          it.printf(padx + 350, pady + 36 * 3, id(mdi), TextAlign::TOP_LEFT, "%s", etc_icon_map["facemask_outline"].c_str());
        }
        it.printf(padx, pady + 36 * 4, id(font1), TextAlign::TOP_LEFT, "✽ 초미세먼지: %.0f㎍", id(outdoor_pm25).state);
        if(id(outdoor_pm25).state > 10) { 
          it.printf(padx + 350, pady + 36 * 4, id(mdi), TextAlign::TOP_LEFT, "%s", etc_icon_map["facemask_outline"].c_str());
        }

        it.printf(padx, pady + 36 * 5, id(font1), TextAlign::TOP_LEFT, "[ 실내 ]");
        it.printf(padx, pady + 36 * 6, id(font1), TextAlign::TOP_LEFT, "✽ 초미세먼지: %.0f㎍", id(indoor_pm25).state);
        if(id(indoor_pm25).state > 5) { 
          it.printf(padx + 350, pady + 36 * 6, id(mdi), TextAlign::TOP_LEFT, "%s", etc_icon_map["airfilter"].c_str());
        }
        it.printf(padx, pady + 36 * 7, id(font1), TextAlign::TOP_LEFT, "✽ 이산화탄소: %.0fppm", id(co2).state);
        if(id(co2).state > 800) { 
          it.printf(padx + 350, pady + 36 * 7, id(mdi), TextAlign::TOP_LEFT, "%s", etc_icon_map["window_open_variant"].c_str());
        }

        it.printf(padx, pady + 36 * 8, id(font1), TextAlign::TOP_LEFT, "[ 오늘 ]");              
        it.printf(padx, pady + 36 * 9, id(font1), TextAlign::TOP_LEFT, "✽ 현재 날씨: %s", id(nowweather).state.c_str());
        it.printf(padx + 350, pady + 36 * 9, id(mdi), color_red, TextAlign::TOP_LEFT, "%s",  weather_icon_map[id(nowweather).state.c_str()].c_str());
        it.printf(padx, pady + 36 * 10, id(font1), TextAlign::TOP_LEFT, "✽ 현재 온도: %.0f℃", id(nowtemp).state);
        it.printf(padx, pady + 36 * 11, id(font1), TextAlign::TOP_LEFT, "✽ 최고 온도: %.0f℃", id(todaymaxtemp).state);
        it.printf(padx, pady + 36 * 12, id(font1), TextAlign::TOP_LEFT, "✽ 비 시작: %s", id(todayraintime).state.c_str());
        if(strcmp(id(todayraintime).state.c_str(), "비안옴") != 0) { 
          it.printf(padx + 350, pady + 36 * 12, id(mdi), TextAlign::TOP_LEFT, "%s", etc_icon_map["umbrella_closed_outline"].c_str());
        }
        it.printf(padx, pady + 36 * 13, id(font1), TextAlign::TOP_LEFT, "[ 내일 ]");  
        it.printf(padx, pady + 36 * 14, id(font1), TextAlign::TOP_LEFT, "✽ 오전 날씨: %s", id(tomorrowweather1).state.c_str());
        it.printf(padx, pady + 36 * 15, id(font1), TextAlign::TOP_LEFT, "✽ 오후 날씨: %s", id(tomorrowweather2).state.c_str());
        it.printf(padx, pady + 36 * 16, id(font1), TextAlign::TOP_LEFT, "✽ 최고 온도: %.0f℃", id(tomorrowmaxtemp).state);
        it.printf(padx, pady + 36 * 17, id(font1), TextAlign::TOP_LEFT, "✽ 최저 온도: %.0f℃", id(tomorrowmintemp).state);
      } else { 
        it.printf(padx, pady + 36 * 8, id(font1), TextAlign::TOP_LEFT,"✽✽ 잠시만 기다리세요... ✽✽"); 
      }