iTranslated by AI
Building a Cat Monitoring System: Part 5 - Integrating SwitchBot Control and Camera Viewer with tkinter
What I achieved in this article
- Added the following functions to the SwitchBot control GUI created in Part 4.
- Tapo C210 camera viewer
- Pan/Tilt/Zoom functionality
- Privacy mode ON/OFF functionality
- Still image saving functionality
- Video saving functionality (with audio)
- Original motion detection functionality (simple version)
- Display of the last detected motion time
- Volume bar
- Log recording
Remaining Tasks
- Privacy mode related ※Depreciated in Tapo FW 1.5.x?
- Pan/Tilt related ※Depreciated in Tapo FW 1.5.x?
- Improvement of motion detection performance (targeting only cats)
Environment
As of January 30, 2026, it seems that Tapo FW 1.5.x? is no longer compatible with ONVIF PTZ and pytapo, making it impossible to perform PTZ operations or toggle privacy mode.
Updating Python or the pytapo library to the latest versions has not resolved the issue.
- Windows 11
- SwitchBot App Version: 7.3
- SwitchBot Hub mini Firmware Version: V6.1-5.4
- Tapo Firmware: 1.5.1
- Python 3.13.11 (64bit)
- Refer to the following for Python libraries:
pip install ffmpeg-python==0.2.0
pip install opencv-python==4.13.0.90
pip install plyer==2.1.0
pip install pygame==2.6.1
pip install pytapo==3.3.56
pip install requests==2.32.5
pip install rtsp==1.1.12
Important Notes
Since it is highly likely that existing code will become unusable due to FW updates, it is recommended to turn OFF Tapo app updates and camera FW updates if you want to operate the Tapo app from a PC.
Completed Screen

I was able to successfully obtain both video and still images!
Directory Structure
├── SmartHome.py # GUI main (Tkinter)
├── util/
│ ├── __init__.py # Empty file to make the directory recognized as a package
│ ├── motion_detector.py # Motion detection (simple version)
│ ├── onvif.py # ONVIF PTZ
│ ├── sb_command.py # SwitchBot operation commands
│ ├── switchbot.py # For SwitchBot operation
│ └── recorder.py # Recording and audio capturing
├── icon/
│ └── Switchbot.png # GUI icon
└── sound/
└── shutter.mp3 # Sound for still image capture
Source Code
GUI Main
import base64
import datetime
import json
import os
import subprocess
import sys
import time
import threading
import tkinter as tk
import tkinter.ttk as ttk
from tkinter import filedialog, font, scrolledtext
import urllib.request
import cv2
import numpy as np
from PIL import Image, ImageTk
from plyer import notification
#from pytapo import Tapo
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
import rtsp
from util.motion_detector import MotionDetector
#from util.onvif import Onvif
import util.sb_command as sb_cmd
from util.switchbot import SwitchBotController
from util.recoder import Recorder
#==================================
# Connection settings with SwitchBot Hub mini
#==================================
# How to obtain the token
# (1) Launch the SwitchBot App.
# (2) Open "Settings" in "Profile"
# (3) Tap the "App Version" section 10 times.
# (4) "Developer Options" will appear, so open it.
# (5) Obtain the token.
TOKEN = ''
API_HOST = 'https://api.switch-bot.com'
DEBIVELIST_URL = f'{API_HOST}/v1.0/devices'
HEADERS = {
'Authorization': TOKEN,
'Content-Type': 'application/json; charset=utf8'
}
#==================================
# Tapo C210
#==================================
CAM_USER = "" # Adjust to your environment
CAM_PASS = "" # Adjust to your environment. Use the camera's own password, not the Tapo app password
CAM_ADDR = "" # Adjust to your environment.
CAM_PORT = "554"
CAM_STREAM = "stream1" # stream1 is 1080p(1920x1080) + with audio, stream2 is 360p(640x360) without audio
CAM_ONVIF_PORT = "2020"
# Pan/Tilt parameters
X_STP = 0.1
Y_STP = 0.1
# Default position moved to by the camera's center button. Range -1 to 1
X_DEFALUT = -0.4
Y_DEFALUT = 0.2
#==================================
# UI Settings
#==================================
icon_path = os.path.join('icon', 'Switchbot.png')
# Font (Using DSEG v046)
# https://www.keshikan.net/fonts.html
FONT_DIG = 'DSEG14 Classic Mini'
# Resolution when obtaining images from the camera.
if CAM_STREAM == "stream1":
FRAME_WIDTH = 1920
FRAME_HEIGHT = 1080
else:
FRAME_WIDTH = 640
FRAME_HEIGHT = 360
# Canvas size
CANVAS_WIDTH = 640
CANVAS_HEIGHT = 360
#==================================
# Others
#==================================
# Folder name for saving video and still images
VIDEO_DIR = 'Video'
# Volume meter offset obtained from calibration
# If sound level meter app Decibel X is 28.5dB, and Tapo is -61.9dBFS
# 28.5 - (-61.9) = 90.4
DB_OFFSET = 90.4
# Size of 1 PCM frame. For 0.1 seconds (16000Hz * 0.1sec)
CHUNK = 1600
class Application(tk.Frame):
def __init__(self, master=None):
super().__init__(master)
# SwitchBot
self.sb = SwitchBotController(
TOKEN,
API_HOST,
logger=self._logger
)
self.temperature, self.humidity = self.sb.get_meter(sb_cmd.DIC_DIV_ID.get('Meter'))
# Storage for still images and videos
if not os.path.exists(VIDEO_DIR):
os.makedirs(VIDEO_DIR)
# Recording/audio related
self.capture_flag = False
self.record_flag = False
self.rec_start_time = 0.0
self.writer = None
self.audio_fp = None
self.video_file = None
self.audio_file = None
self.recorder = Recorder(
video_dir=VIDEO_DIR,
canvas_size=(FRAME_WIDTH, FRAME_HEIGHT),
logger=self._logger
)
# Motion detection
self.motion = MotionDetector()
self.last_detected_time = "--:--"
self.label_detected_time = None
# Control Tapo C210 via ONVIF PTZ
# Tapo FW 1.5.1 returns "HTTP Error 400: Bad Request"
"""
self.onvif = Onvif(
cam_addr=CAM_ADDR,
cam_port=CAM_ONVIF_PORT,
user=CAM_USER,
password=CAM_PASS,
logger=self._logger
)
"""
#####################################################
# Open the camera
#####################################################
self.rtsp_url = f"rtsp://{CAM_USER}:{CAM_PASS}@{CAM_ADDR}:{CAM_PORT}/{CAM_STREAM}"
# For video acquisition
try:
self.rstp = subprocess.Popen(
[
"ffmpeg",
"-rtsp_transport", "tcp",
"-i", self.rtsp_url,
"-an", # No audio
"-vf", f"scale={FRAME_WIDTH}:{FRAME_HEIGHT}", # Fix output size
"-pix_fmt", "rgb24",
"-f", "rawvideo",
"-"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
print("Successfully connected to the camera via RTSP")
except Exception as e:
print("Failed to connect to the camera via RTSP")
print(e)
sys.exit(1)
# For audio acquisition (shared for progress bar + recording)
try:
self.ffmpeg_audio = subprocess.Popen(
[
"ffmpeg",
"-rtsp_transport", "tcp",
"-i", self.rtsp_url,
"-vn", # No video
"-acodec", "pcm_s16le", # 16bit PCM
"-ac", "1", # Mono
"-ar", "16000", # Sampling rate
"-f", "s16le", # raw PCM
"-"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
print("Successfully connected to the camera via RTSP (audio)")
except Exception as e:
print("Failed to connect to the camera via RTSP (audio)")
print(e)
sys.exit(1)
"""
# For privacy mode, Pan/Tilt.
# As of January 30, 2026, cannot connect to Tapo FW 1.5.1 from pytapo
try:
self.pytapo = Tapo(CAM_ADDR, CAM_USER, CAM_PASS)
# Current privacy mode value
json_object = self.pytapo.getPrivacyMode()
p_mode = json_object['enabled']
print('Current privacy mode value: ' + p_mode)
print("pytapo successfully connected to the camera")
except Exception as e:
print("pytapo failed to connect to the camera")
print(e)
sys.exit(1)
"""
#####################################################
# GUI Design
#####################################################
#==================================
# Window Creation
#==================================
self.pack()
master.title('Smart Home Controller')
master.geometry('840x600')
master.resizable(False, False) # Set to non-resizable
# Icon setting
if os.path.exists(icon_path):
photo = tk.PhotoImage(file=icon_path)
master.iconphoto(False, photo)
#==================================
# Define Tabs
#==================================
frame_top = tk.Frame(master, width=840, height=600)
frame_top.propagate(False)
frame_top.place(x=0, y=0)
# Create notebook instance
nb = ttk.Notebook(master, width=840, height=545)
nb.place(x=0, y=40)
tab1 = tk.Frame(nb)
tab2 = tk.Frame(nb)
tab3 = tk.Frame(nb)
# Tab label settings
nb.add(tab1, text='Living Room', padding=4)
nb.add(tab2, text='Bedroom', padding=4)
nb.add(tab3, text='log', padding=4)
#==================================
# Define Frames for Tab1
#==================================
# tab1
frame1 = tk.Frame(tab1, width=100, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
frame2 = tk.Frame(tab1, width=240, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
frame3 = tk.Frame(tab1, width=170, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
frame4 = tk.Frame(tab1, width=170, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
frame5 = tk.Frame(tab1, width=125, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
frame_camera = tk.Frame(tab1, width=825, height=415, borderwidth=1, relief='solid', padx=5, pady=3)
# Fix frame sizes
frame1.propagate(False)
frame2.propagate(False)
frame3.propagate(False)
frame4.propagate(False)
frame5.propagate(False)
frame_camera.propagate(False)
# Place frames
frame1.place(x=0, y=0)
frame2.place(x=105, y=0)
frame3.place(x=350, y=0)
frame4.place(x=525, y=0)
frame5.place(x=700, y=0)
frame_camera.place(x=0, y=100)
#==================================
# Widget Placement for Tab1
#==================================
# frame_top: Date and time (YYYY/MM/DD hh:mm)
self.label_ta = tk.Label(frame_top, font=('Meiryo', 14, 'bold'))
self.label_ta.place(x=0, y=0)
self._update_clock()
#-----------------------------
# frame1: Display thermo-hygrometer info
#-----------------------------
# Thermo-hygrometer label
label_f1 = tk.Label(frame1, text='Meter', font=('Meiryo', 12))
label_f1.place(x=0, y=0)
# Temperature
self.label_temperature = tk.Label(
frame1,
text=self.temperature,
fg='green',
bg='lightgray',
font=(FONT_DIG, 12)
)
self.label_temperature.place(x=10, y=30)
# Unit: Celsius
label_temperature_unit = tk.Label(frame1, text='°C', font=('Meiryo', 10))
label_temperature_unit.place(x=60, y=30)
# Humidity
self.label_moisture = tk.Label(
frame1,
text=self.humidity,
fg='green',
bg='lightgray',
font=(FONT_DIG, 12)
)
self.label_moisture.place(x=10, y=60)
self._update_meter()
# Unit: %
label_moisture_unit = tk.Label(frame1, text='%', font=('Meiryo', 10))
label_moisture_unit.place(x=60, y=60)
#-----------------------------
# frame2: AC control
#-----------------------------
# AC label
label_f2 = tk.Label(frame2, text='AC', font=('Meiryo', 12))
label_f2.place(x=0, y=0)
# Buttons
button_2a = tk.Button(frame2, text='Cool 28°', width='8', command=lambda: self._click_btn_sw('air_cool28'))
button_2a.place(x=10, y=30)
button_2b = tk.Button(frame2, text='Dry', width='8', command=lambda: self._click_btn_sw('air_dry'))
button_2b.place(x=80, y=30)
button_2c = tk.Button(frame2, text='Heat 25°', width='8', command=lambda: self._click_btn_sw('air_hot25'))
button_2c.place(x=150, y=30)
button_2d = tk.Button(frame2, text='OFF', width='8', command=lambda: self._click_btn_sw('air_off'), bg='red')
button_2d.place(x=10, y=60)
#-----------------------------
# frame3: Living room light control
#-----------------------------
# Light label
label_f3 = tk.Label(frame3, text='Lighting', font=('Meiryo', 12))
label_f3.place(x=0, y=0)
# Buttons
button_3a = tk.Button(frame3, text='ON', width='8', command=lambda: self._click_btn_sw('light_on'))
button_3a.place(x=10, y=30)
button_3b = tk.Button(frame3, text='Bright▲', width='8', command=lambda: self._click_btn_sw('light_up'))
button_3b.place(x=80, y=30)
button_3c = tk.Button(frame3, text='OFF', width='8', command=lambda: self._click_btn_sw('light_off'), bg='red')
button_3c.place(x=10, y=60)
button_3d = tk.Button(frame3, text='Dim▼', width='8', command=lambda: self._click_btn_sw('light_down'))
button_3d.place(x=80, y=60)
#-----------------------------
# frame4: TV control
#-----------------------------
# TV label
label_f4 = tk.Label(frame4, text='TV', font=('Meiryo', 12))
label_f4.place(x=0, y=0)
# Buttons
button_4a = tk.Button(frame4, text='ON', width='8', command=lambda: self._click_btn_sw('tv_on'))
button_4a.place(x=10, y=30)
button_4b = tk.Button(frame4, text='Vol▲', width='8', command=lambda: self._click_btn_sw('tv_vup'))
button_4b.place(x=80, y=30)
button_4c = tk.Button(frame4, text='OFF', width='8', command=lambda: self._click_btn_sw('tv_off'), bg='red')
button_4c.place(x=10, y=60)
button_4d = tk.Button(frame4, text='Vol▼', width='8', command=lambda: self._click_btn_sw('tv_vdown'))
button_4d.place(x=80, y=60)
#-----------------------------
# frame5: Spare area
#-----------------------------
#-----------------------------
# frame_camera: Camera operation
#-----------------------------
# Camera label
label_f5 = tk.Label(frame_camera, text='Camera', font=('Meiryo', 12))
label_f5.place(x=0, y=0)
# Camera feed display
self.canvas = tk.Canvas(frame_camera, width=640, height=360, bg='white')
self.canvas.place(x=10, y=30)
#.......................
# Camera Operation Menu
#.......................
# Placement offset
op_offset_x = 350 + 310
op_offset_y = 0
# Privacy mode ON/OFF (TBD)
# As of January 30, 2026, cannot connect to Tapo FW 1.5.1 from pytapo
self.check_privacy_flag = tk.BooleanVar(value=True)
check_privacy = tk.Checkbutton(
frame_camera,
text="Privacy Mode",
font=font.Font(family="Meiryo", size=10, overstrike=True),
variable=self.check_privacy_flag,
onvalue=True,
offvalue=False
)
check_privacy.place(x=op_offset_x, y=op_offset_y + 30)
# Motion detection result ON/OFF
self.check_move_flag = tk.BooleanVar(value=True)
check_move = tk.Checkbutton(
frame_camera,
text="Detection Result(Red)",
font=font.Font(family="Meiryo", size=10, overstrike=False),
variable=self.check_move_flag,
onvalue=True,
offvalue=False
)
check_move.place(x=op_offset_x, y=op_offset_y + 50)
# Last detection time
label_last_detected_time = tk.Label(frame_camera, text='Last Detected:', font=('Meiryo', 10))
label_last_detected_time.place(x=op_offset_x + 20, y=op_offset_y + 70)
self.label_detected_time = tk.Label(frame_camera, text=self.last_detected_time, font=('Meiryo', 10))
self.label_detected_time.place(x=op_offset_x + 110, y=op_offset_y + 70)
# Video capture button
op_offset_y = 125
button_recode = tk.Button(frame_camera, text='Record', width='8', command=self._click_cam_rec_btn)
button_recode.place(x=op_offset_x, y=op_offset_y)
# Still image capture button
button_capture = tk.Button(frame_camera, text='Capture', width='8', command=self._click_cam_cap_btn)
button_capture.place(x=op_offset_x + 75, y=op_offset_y)
# Pan/Tilt label (TBD)
# As of January 30, 2026, ONVIF PTZ cannot be used with Tapo FW 1.5.1. Same for pytapo.
op_offset_y = 160
label_pt = tk.Label(
frame_camera,
text='Pan/Tilt',
font=font.Font(family="Meiryo", size=10, overstrike=True)
)
label_pt.place(x=op_offset_x, y=op_offset_y)
# Pan/Tilt buttons (TBD)
# As of January 30, 2026, ONVIF PTZ cannot be used with Tapo FW 1.5.1. Same for pytapo.
op_offset_y = 180
button_pt_lt = tk.Button(frame_camera, text='\', width='2', command=lambda: self._click_btn_cam_pan(-1, 1))
button_pt_lt.place(x=op_offset_x + 10, y=op_offset_y)
button_pt_mt = tk.Button(frame_camera, text='↑', width='2', command=lambda: self._click_btn_cam_pan(0, 1))
button_pt_mt.place(x=op_offset_x + 40, y=op_offset_y)
button_pt_rt = tk.Button(frame_camera, text='/', width='2', command=lambda: self._click_btn_cam_pan(1, 1))
button_pt_rt.place(x=op_offset_x + 70, y=op_offset_y)
op_offset_y = 210
button_pt_lm= tk.Button(frame_camera, text='←', width='2', command=lambda: self._click_btn_cam_pan(-1, 0))
button_pt_lm.place(x=op_offset_x + 10, y=op_offset_y)
button_pt_mm = tk.Button(frame_camera, text='+', width='2', command=lambda: self._click_btn_cam_pan(X_DEFALUT, Y_DEFALUT))
button_pt_mm.place(x=op_offset_x + 40, y=op_offset_y)
button_pt_rm = tk.Button(frame_camera, text='→', width='2', command=lambda: self._click_btn_cam_pan(1, 0))
button_pt_rm.place(x=op_offset_x + 70, y=op_offset_y)
op_offset_y = 240
button_pt_lb = tk.Button(frame_camera, text='/', width='2', command=lambda: self._click_btn_cam_pan(-1, -1))
button_pt_lb.place(x=op_offset_x + 10, y=op_offset_y)
button_pt_mb = tk.Button(frame_camera, text='↓', width='2', command=lambda: self._click_btn_cam_pan(0, -1))
button_pt_mb.place(x=op_offset_x + 40, y=op_offset_y)
button_pt_rb = tk.Button(frame_camera, text='\', width='2', command=lambda: self._click_btn_cam_pan(1, -1))
button_pt_rb.place(x=op_offset_x + 70, y=op_offset_y)
# Zoom label
op_offset_y = 270
label_zoom = tk.Label(frame_camera, text='Zoom', font=('Meiryo', 10))
label_zoom.place(x=op_offset_x, y=op_offset_y)
# Zoom slider
op_offset_y = 290
self.zoom_var = tk.DoubleVar()
scale_zoom = ttk.Scale(
frame_camera,
variable=self.zoom_var,
orient=tk.HORIZONTAL, # orientation
length=100, # length
from_=1.0, # min
to=4.0 # max
)
scale_zoom.place(x=op_offset_x + 10, y=op_offset_y)
# Scale ticks
op_offset_y = 310
label_zoom_1 = tk.Label(frame_camera, text='1', font=('Meiryo', 7))
label_zoom_1.place(x=op_offset_x + 10, y=op_offset_y)
label_zoom_2 = tk.Label(frame_camera, text='2', font=('Meiryo', 7))
label_zoom_2.place(x=op_offset_x + 54, y=op_offset_y)
label_zoom_4 = tk.Label(frame_camera, text='4', font=('Meiryo', 7))
label_zoom_4.place(x=op_offset_x + 100, y=op_offset_y)
# Volume label
op_offset_y = 340
label_volume = tk.Label(frame_camera, text='Vol[dB]', font=('Meiryo', 10))
label_volume.place(x=op_offset_x, y=op_offset_y)
# Display volume with Progressbar
op_offset_y = 360
self.vol = tk.IntVar()
bar_vol = ttk.Progressbar(frame_camera, maximum=100, mode="determinate", variable=self.vol)
bar_vol.place(x=op_offset_x + 10, y=op_offset_y)
# Scale ticks
op_offset_y = 385
label_0db = tk.Label(frame_camera, text='0', font=('Meiryo', 7))
label_0db.place(x=op_offset_x + 10, y=op_offset_y)
label_50db = tk.Label(frame_camera, text='50', font=('Meiryo', 7))
label_50db.place(x=op_offset_x + 50, y=op_offset_y)
label_100db = tk.Label(frame_camera, text='100', font=('Meiryo', 7))
label_100db.place(x=op_offset_x + 100, y=op_offset_y)
#==================================
# Define Tab2
#==================================
# TBD
#==================================
# Define Tab3
#==================================
self.text_area = scrolledtext.ScrolledText(
tab3,
width=115,
height=38,
font=('Source Han Code JP', 10),
)
self.text_area.grid(column=0, pady=5, padx=5)
self.text_area.focus()
# Start camera display
self._disp_image()
# Start volume meter thread (permanent)
threading.Thread(target=self._audio_thread, daemon=True).start()
def _disp_image(self):
"""
Process camera feed and display on Canvas.
"""
#==================================
# Obtain video from camera
#==================================
no_frame_cnt = 0
while True:
in_bytes = self.rstp.stdout.read(FRAME_WIDTH * FRAME_HEIGHT * 3)
if in_bytes:
frame = np.frombuffer(in_bytes, np.uint8).reshape([FRAME_HEIGHT, FRAME_WIDTH, 3])
break
# If nothing obtained for 1 minute, handle reconnection or camera restart (TBD)
if no_frame_cnt 2 15: # 15fps, so 15 frames per second
self._logger("Could not obtain frame for 1 minute.")
no_frame_cnt = 0
no_frame_cnt += 1
time.sleep(0.05)
#==================================
# Convert for OpenCV (motion detection/recording)
#==================================
cv_frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
#==================================
# Motion Detection
#==================================
if not self.check_move_flag.get():
self.motion.reset()
else:
detect = False
color_blue = (0, 0, 255) # Border color (Blue - variable name mismatch in original code, kept logic same)
# Detect movement
rects = self.motion.detect(cv_frame)
# If too many detections, assume camera is Pan/Tilting and don't draw anything (adjust needed)
if 300 2 len(rects):
# Draw differences on screen
for (x, y, w, h) in rects:
# Ignore small or too large changes
if 200 2 w 2 20 and 200 2 h 2 20:
cv2.rectangle(
cv_frame, # Target image
(x, y), # Top-left coordinates
(x + w, y + h), # Bottom-right coordinates
(0, 0, 255), # Line color. RED
2 # Line thickness
)
detect = True
if detect:
self._logger("Motion detected")
# Update last detection time
now = datetime.datetime.now().strftime("%H:%M")
self.last_detected_time = now
self.label_detected_time.config(text=now)
#==================================
# Still image save
#==================================
if self.capture_flag:
self.recorder.save_image(cv_frame)
self.capture_flag = False
#==================================
# Video save (frame writing + REC display)
#==================================
if self.record_flag:
# Write frame
self.recorder.write_video(cv_frame)
# Measure time since recording started
rec_time = time.perf_counter() - self.rec_start_time
rec_timestamp = '{0:02d}:{1:02d}:{2:06.3f}'.format(
int(rec_time // 3600),
int(rec_time // 60),
rec_time % 60
)
# Since putText() only supports ASCII, draw a red circle using circle()
cv2.circle(
cv_frame,
(20, FRAME_HEIGHT - 23), # Position
14, # Size
(0, 0, 255), # RED
-1
)
# Add "REC" and recording time display at the bottom of the image (not included in saved file).
cv2.putText(
cv_frame,
f"REC {rec_timestamp}",
(36, FRAME_HEIGHT - 12), # Position
cv2.FONT_HERSHEY_SIMPLEX,
1.0, # Font size
(0, 0, 255), # RED
3,
cv2.LINE_AA
)
# Auto-stop after 5 minutes
if rec_time 2= 5 * 60:
self._logger("Automatically stopping recording after 5 minutes")
self.recorder.stop()
self.record_flag = False
#==================================
# Resize for display.
#==================================
# Resize if CANVAS_WIDTH and FRAME_WIDTH differ
if CANVAS_WIDTH != FRAME_WIDTH:
cv_frame = cv2.resize(cv_frame, (CANVAS_WIDTH, CANVAS_HEIGHT), interpolation=cv2.INTER_LINEAR )
# Get settings
if self.zoom_var.get() == 0:
zoom = 1
else:
zoom = self.zoom_var.get()
# Resize image based on canvas size multiplied by zoom factor.
# There is a moment when self.zoom_var.get() is 0 during window creation.
if zoom != 1:
h, w = cv_frame.shape[:2]
new_w = int(w * zoom)
new_h = int(h * zoom)
# Zoom from center
cv_frame = cv2.resize(cv_frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
# Crop the center back to canvas size
x1 = (new_w - CANVAS_WIDTH) // 2
y1 = (new_h - CANVAS_HEIGHT) // 2
cv_frame = cv_frame[y1:y1 + CANVAS_HEIGHT, x1:x1 + CANVAS_WIDTH]
#==================================
# Convert to PhotoImage for Canvas rendering
#==================================
disp_frame = cv2.cvtColor(cv_frame, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(disp_frame)
self.photo_image = ImageTk.PhotoImage(image=pil_image)
# Render image
self.canvas.delete("all")
self.canvas.create_image(
CANVAS_WIDTH / 2, # Image display position (Center of Canvas)
CANVAS_HEIGHT / 2,
image=self.photo_image # Display image data
)
# Execute _disp_image() every 10ms
self.after(10, self._disp_image)
def _click_btn_sw(self, key=None):
"""
Action when a SwitchBot operation button is pressed
"""
self._logger('push {} botton'.format(key))
device_id = sb_cmd.DIC_CMD.get(key)[0]
params = sb_cmd.DIC_CMD.get(key)[1]
self.sb.post_request(device_id, params)
def _click_btn_cam_pan(self, x, y):
"""
Action when a Pan/Tilt button is pressed
x, y are directions -1, 0, 1
For Tapo C210, speed values too small are ignored, so 0.3+ is recommended
"""
step = 0.4
# xml = self.onvif.build_relative_move_xml(x * step, y * step)
# self.onvif.send_onvif(xml)
def _click_cam_cap_btn(self):
"""
When still image capture button is pressed
"""
self.capture_flag = True
def _click_cam_rec_btn(self):
"""
When recording button is pressed
"""
if not self.record_flag:
self.recorder.start()
self.record_flag = True
self.rec_start_time = time.perf_counter()
else:
self.recorder.stop()
self.record_flag = False
def _audio_thread(self):
"""
Audio thread:
- Constantly reads RTSP audio, calculates volume and updates progress bar
- Only during recording, writes PCM from the same stream to a file
"""
while True:
data = self.ffmpeg_audio.stdout.read(CHUNK * 2)
if not data:
continue
# Volume meter processing
self.db = self._pcm_to_db(data)
# Update Tkinter in the main thread
root.after(0, self.vol.set, self.db)
# Write to file if recording
if self.record_flag:
self.recorder.write_audio(data)
def _pcm_to_db(self, pcm):
"""
Converts PCM16 numpy array to dBFS,
and returns a calibrated "sound meter style dB".
"""
try:
audio = np.frombuffer(pcm, dtype=np.int16)
except Exception:
return -90
if len(audio) == 0:
return -90
# Normalization
norm = np.abs(audio).astype(np.float64) / 32768.0
# Remove abnormal values (NaN, Inf)
norm = np.nan_to_num(norm, nan=0.0, posinf=1.0, neginf=0.0)
# Clip to prevent negative values
safe = np.clip(norm + 1e-6, 1e-12, None)
# Calculate dBFS
ln_val = np.log(safe)
dbfs_arr = 20 * ln_val / np.log(10)
# Remove abnormal values
dbfs_arr = np.nan_to_num(dbfs_arr, nan=-120.0, posinf=0.0, neginf=-120.0)
# Mean value
dbfs = float(np.mean(dbfs_arr))
if np.isnan(dbfs) or np.isinf(dbfs):
return -90
# Add calibrated DB_OFFSET
db = int(dbfs + DB_OFFSET)
return db
def _logger(self, text='', level='INFO'):
"""
Output log to Log tab and log file
"""
dt = datetime.datetime.now()
date = dt.strftime('%Y/%m/%d %H:%M:%S')
line = f'[{date}][{level:27}] {text}'
# Output to GUI
# Fall back to print if text_area doesn't exist yet
if hasattr(self, "text_area") and self.text_area is not None:
self.text_area.insert(tk.END, line + '\n')
self.text_area.see(tk.END)
else:
print(line)
# Output to file
log_path = "log.txt"
try:
with open(log_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as e:
print(f"Log write error: {e}")
# Size control (trim old lines if exceeding 10KB)
try:
if os.path.exists(log_path) and os.path.getsize(log_path) 2 10 * 1024:
with open(log_path, "r", encoding="utf-8") as f:
lines = f.readlines()
# Delete old lines (cut in half)
keep = lines[len(lines)//2:]
with open(log_path, "w", encoding="utf-8") as f:
f.writelines(keep)
# Record in log
print("log.txt trimmed (size 2 10KB)")
except Exception as e:
print(f"Log trim error: {e}")
def _update_clock(self):
'''
Update date and time every second
Format: YYYY/MM/DD hh:mm:ss
'''
now = datetime.datetime.now().strftime('%Y/%m/%d(%a) %H:%M:%S')
self.label_ta.config(text=now)
self.after(1000, self._update_clock)
def _update_meter(self):
'''
Update thermo-hygrometer every 5 minutes
'''
device_id = sb_cmd.DIC_DIV_ID.get('Meter')
temp, hum = self.sb.get_meter(device_id)
if temp is not None:
self.label_temperature.config(text=temp)
if hum is not None:
self.label_moisture.config(text=hum)
self.after(5 * 60 * 1000, self._update_meter)
if __name__ == "__main__":
root = tk.Tk()
app = Application(master=root)
app.mainloop()
Motion Detection
This is a simple version.
In the future, I would like to detect only cats using machine learning.
Reference site:
import cv2
import numpy as np
class MotionDetector:
def __init__(self, blur_size=(21, 21), threshold=25):
self.avg = None # For saving the previous frame
self.blur_size = blur_size
self.threshold = threshold # Threshold for binarization (needs adjustment)
def reset(self):
"""
Reset the average frame for motion detection
"""
self.avg = None
def detect(self, frame):
"""
Detect movement and return a list of rectangles for the moved areas
frame: BGR (OpenCV frame)
return: [(x, y, w, h), ...]
"""
# Convert to grayscale
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, self.blur_size, 0)
# Just create the average frame for the first time
if self.avg is None:
self.avg = gray.copy().astype("float")
return []
# Calculate the difference between the current frame and the moving average
cv2.accumulateWeighted(gray, self.avg, 0.5)
frame_delta = cv2.absdiff(gray, cv2.convertScaleAbs(self.avg))
# Binarize the frameDelta image
# If the threshold is too small, false positives increase; if too large, detection performance decreases.
thresh = cv2.threshold(frame_delta, self.threshold, 255, cv2.THRESH_BINARY)[1]
thresh = cv2.dilate(thresh, None, iterations=2)
# Extract contours
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Change format
rects = []
for c in contours:
x, y, w, h = cv2.boundingRect(c)
rects.append((x, y, w, h))
return rects
ONVIF PTZ
onvif.py is a file based on Article 3, but it is omitted here because it returns a Bad Request in Tapo FW 1.5.1 and cannot be used properly.
For SwitchBot Operation
sb_command.py is omitted because it is common with Article 2.
import requests
import json
from requests.exceptions import HTTPError, RequestException, Timeout
class SwitchBotController:
def __init__(self, token, api_host, logger=None):
self.token = token
self.api_host = api_host
self.logger = logger or (lambda msg: None)
self.headers = {
'Authorization': token,
'Content-Type': 'application/json; charset=utf8'
}
def _log(self, msg, level="INFO"):
"""
Passes logs to the GUI's Log tab
"""
if self.logger:
self.logger(msg, level)
def _get_device_status(self, device_id):
"""
Obtains the status of a Switchbot device.
"""
url = f"{self.api_host}/v1.0/devices/{device_id}/status"
try:
r = requests.get(url, headers=self.headers)
r.raise_for_status()
body = r.json()["body"]
# self._log(f"SwitchBot: status {device_id} → OK")
return body
except Timeout:
self._log(f"SwitchBot timeout error: ", 'ERROR')
except Exception as e:
self._log(f"SwitchBot error: {e}", 'ERROR')
raise
def get_meter(self, device_id):
"""
Obtains and returns the temperature and humidity of the thermo-hygrometer
"""
try:
status = self._get_device_status(device_id)
temp = '{0:4.1f}'.format(status.get('temperature'))
hum = '{0:4.1f}'.format(status.get('humidity'))
self._log(f"SwitchBot Thermo-hygrometer OK: {temp}°C {hum}%")
return temp, hum
except Exception as e:
self._log(f"SwitchBot Thermo-hygrometer error: {e}")
return None, None
def post_request(self, device_id, params):
'''
Issues instructions to the device.
'''
url = f"{self.api_host}/v1.0/devices/{device_id}/commands"
try:
res = requests.post(url, data=json.dumps(params), headers=self.headers)
data = res.json()
if data['message'] == 'success':
self._log(f"SwitchBot Command OK")
elif data['message'] == 'device offline':
self._log(f"SwitchBot Command NG. Device {device_id} is offline")
else:
self._log(f"SwitchBot error: {data}", 'ERROR')
return data
except Timeout:
self._log(f"SwitchBot timeout error: ", 'ERROR')
except Exception as e:
self._log(f"SwitchBot error: {e}", 'ERROR')
raise
For Recording and Audio
import datetime
import os
import subprocess
import wave
import cv2
from pygame import mixer
# Sound for still image capture
mixer.init()
SHUTTER_SOUND = mixer.Sound(os.path.join('../', 'sound', 'shutter.mp3'))
class Recorder:
def __init__(self, video_dir="Video", canvas_size=(640, 360), logger=None):
self.video_dir = video_dir
# Video file name without audio
self.video_file_temp = os.path.join(self.video_dir, f"Video_temp.mp4")
self.canvas_size = canvas_size
self.logger = logger or (lambda msg: None) # Default does nothing
os.makedirs(video_dir, exist_ok=True)
self.recording = False
self.video_writer = None
self.audio_fp = None
self.video_file = None
self.audio_file = None
def _log(self, msg, level="INFO"):
"""
Passes logs to the GUI's Log tab
"""
if self.logger:
self.logger(msg, level)
def start(self):
"""
Start recording
"""
if self.recording:
return
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
self.video_file = os.path.join(self.video_dir, f"Video_{timestamp}.mp4")
self.audio_file = os.path.join(self.video_dir, f"Audio_{timestamp}.raw")
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
self.video_writer = cv2.VideoWriter(
self.video_file_temp,
fourcc,
15.0,
self.canvas_size
)
self.audio_fp = open(self.audio_file, "wb")
self.recording = True
self._log("Recorder: Recording started")
def stop(self):
"""
Stop recording
"""
if not self.recording:
return
self.recording = False
if self.video_writer:
self.video_writer.release()
self.video_writer = None
if self.audio_fp:
self.audio_fp.close()
self.audio_fp = None
# Convert raw to wav
wav_file = self.audio_file.replace(".raw", ".wav")
self._raw_to_wav(self.audio_file, wav_file)
# Merge audio and video
self._merge_audio_video(self.video_file_temp, wav_file, self.video_file)
self._log(f"Recorder: Recording ended {self.video_file}")
def write_video(self, frame):
"""
Write video file
"""
if self.recording and self.video_writer:
self.video_writer.write(frame)
def write_audio(self, pcm_bytes):
"""
Write audio file
"""
if self.recording and self.audio_fp:
self.audio_fp.write(pcm_bytes)
def save_image(self, frame):
"""
Save still image
"""
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(self.video_dir, f"ScreenShot_{timestamp}.png")
cv2.imwrite(filename, frame)
self._log(f"Recorder: Still image saved {filename}")
# Shutter sound
SHUTTER_SOUND.play()
def _raw_to_wav(self, raw_path, wav_path):
"""
Convert Raw file to Wav file
"""
with open(raw_path, "rb") as raw:
pcm = raw.read()
with wave.open(wav_path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(16000)
w.writeframes(pcm)
# Delete Raw file
os.remove(raw_path)
def _merge_audio_video(self, video_file, audio_file, output_file):
"""
Combine audio and video files
Delete files before merging.
"""
subprocess.run(
[
"ffmpeg",
"-i", video_file,
"-i", audio_file,
"-c:v", "copy",
"-c:a", "aac",
"-shortest",
output_file
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# Delete files before synthesis
os.remove(video_file)
os.remove(audio_file)
Conclusion
Actually, this viewer was already taking shape around 2023.
While I was putting it off because fixing freeze issues and writing the article was a hassle, the FW was updated, and the implemented functions became unusable 💦
I'm leaving it on the GUI in the hope that it will be usable again someday.
Discussion