A bit of ANNEX32-BASIC-code to control an ESP32 based pan/tilt servo CAM. No ARDUINO-Code, no compiling … just run the interpreter on the ESP32-device and develop, change customize your BASIC-script with your browser in the web interface of the device.
My intention for the hardware and software was: “Keep it simple”.

So I stacked two SG90-servos to carry a “M5CAMERA001 model B”
I used the ANNEX32 BASIC-interpreter in its version 1.43 for ESP32 cameras (M5Stack CAM model B and the native ESP32-CAM)
My web interface was derived from the ANNEX32 developer’s sample code and some useful hints in the fabulous online-help of ANNEX32. Horizontal and vertical position are manualy set by sliders that are integrated in the live-picture. An auto pan can slowly turn the cam.
All possible resolutions can be selected.
Some pictures can be stored in the internal flash ( or better on a large SD-card if inserted in an ESP32-CAM). Please remember the small PSRAM of the devices. I had no problem storing up to 5 photos with 1024×768. The script will refuse to store if too less space is left for the next photo, and an additional “delete” button will allow to delete the old pics.

The simple schematic shows how to wire the M5Camera001 Type B or the ESP32-CAM with the two SG90 servos.
M5CAM has two free pins at the grove-port (io13 and io4) for the servos.
With ESP32-CAM I had to use io12 and the RX-Pin io3 for the second servo

The servos are stacked and glued together with double sideded tape. The camera needs four flexible wires to connect the servos and get the 5V supply voltage via a Vero-board on top of the servo stack. A flexible two wire supply line is turned around the grounding shaft of the lower pan servo.
BTW: I exchanged the original lense of the M5camera with a fisheye-lense.

Do not forget the 470uF capacitor! The servos draw a lot of current if the motors start to move and you may observe “brown out”-messages or crashes of the ESP32, if your PSU can not deliver a stable minimum of 1A@5V.
I drilled a hole in the buttom of the vertical servo to guide the tip of a short screw of a second holding arm .

The web interface in startup mode.
- Set the resolution of life stream
- Enable/disable more settings for life picture
- Make a photo
- AUTO-PAN ON/OFF

The web interface now with opened settings and activated AUTO-PAN
Next things I want to try
# Implement face detection and face recognition, which are unhappily only available in the 320×240 mode. This worked fundamentally well for me in a separate code snippet – except that I now need to “find” some javascript to draw a frame at the returned face-position in the browser window and perhaps to print an apropriate name for one of the seven recognizable stored faces.
# A mode to follow a face with the servos then seems to be the next simple step … BUT: the max resolution for face detection is 320×240 – not really nice to look at. Perhaps I can toggle between low and high resolution to overcome this.
# A mode to automatically regularly take a picture and store it on a FTP-server may be handy
This is my current ANNEX-code for the described functions:
If you do use only a horizontal PAN servo, or even no servo … the code is prepared to simply set some
variables to (de)activate the servo functions and the sliders.
If running on an ESPCAM the onboard white flash LED can be switched ON/OFF by an additional button.
If the camera or the mount is turned or mounted over the top: Look at the variables to invert/mirror the movements or the picture. I have tried to explain the purpose by the names and with some comment-lines.
' A N N E X 3 2 C A M - p r o g r a m
' - to show a life camera picture in browser in some resolutions
' - to controll the pan/tilt servos (manually or auto-pan)
' - to take some still pictures in NV-RAM or on SD-Card
' - to controll the onboard white LED (ESP32CAM only)
' - to set some camera parameters
' Hardware:
' ESP32CAM or M5CAM version "B" with or without two servos
' ESP32CAM : H-Servo at Pin GPIO12; V-Servo at Pin GPIO3 = RX
' M5CAM : H-Servo at Pin GPIO4; V-Servo at Pin GPIO13; pins at grove-port
' DB9JG@ME:COM 2021/06
VERSION$ = "V9.1"
CALL$ = "DB9JG"
'Save snapshots in local NVRAM or SD (if ANNEX runs on the SD)?
'PIC_NUM = 0 ' => do not save or show pics
PIC_NUM = 5 ' number of pics to save <<ATTENTION: NEEDS SUFFICIENT FLASH-RAM OR SD-CARD
MIN_SPACE = 100000 'take pictures only if some space is left on flash or SD-card
'set camera lense orientation according to HW-setup
PIC_V_FLIP = 1 'Vertical orientation 0 = normal, 1 = flipped
PIC_H_MIRROR= 1 'Horizontal orientation 0 = normal, 1 = flipped
'Set servo orientation according to construction of servo mount
SERVO_H_FLIP= 1 'PAN servo 0 = normal, 1 = inverted
SERVO_V_FLIP= 0 'TILT servo 0 = normal, 1 = inverted
'----------------------------------------------
'SERVO range (my settings for two SG90 servos)
SERVO_H_POS = 280 '<<<initial slider position, if 0 then no servo and no slider
SERVO_V_POS = 230 '<<<initial slider position, if 0 then no servo and no slider
SERVO_H_MIN = 100 'horizontal leftmost position
SERVO_H_MAX = 470 'horizontal rightmost position
SERVO_V_MIN = 160 'vertical leftmost position
SERVO_V_MAX = 420 'vertical rightmost position
HSTEP = 12 'STEP width for automatic panning
SHOW_PARAMS = 0 '0 = starts with CLOSED parameter-menue
ms_per_pic = 100 '<< 100ms/pic = 10pics/sec at HTML-page <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
M5CAM = 202 'HW-device-numbers returned by BAS.DEVICE
ESP32CAM = 201
BUT_OFF$ = "butgreen"
BUT_ON$ = "butred"
SHOW_PARAMS_TXT$ = "SETTINGS=OFF"
MAKE_FOTO_COL$ = BUT_OFF$
MAKE_FOTO_TXT$ = "make FOTO"
ret = file.mkdir("/images") 'ensure folder for saving pics
FOTO$ = "/images/PIC_1.jpg"
RES_NEW$ = ""
MAKE_UNIQUE$ = str$(millis)
T$ = ""
L = 0
FOTO_LOCKED = 0
DateTime$ = ""
SHOW_PARAMS_COL$ = BUT_OFF$
SHOW_PARAMS_TXT$ = "SETTINGS=OFF"
LED_ON_OFF_COL$ = BUT_OFF$
LED_ON_OFF_TXT$ = "LED=OFF"
AUTO_PAN_TXT$ = "AUTO_PAN=OFF"
AUTO_PAN_COL$ = BUT_OFF$
INFO$ = CALL$ + "-CAM " + VERSION$
option.wdt 10000 'watchdog timer 10seconds
wifi.power 20 'maximize wifi-power
'SERVO-setup if used with pan/tilt servos
If SERVO_H_POS then gosub SERVO_H_INIT
If SERVO_V_POS then gosub SERVO_V_INIT
'use onboard LEDs only at ESP32CAM not with M5CAM
if BAS.DEVICE = ESP32CAM then
pin.mode 33, output 'red LED
pin.mode 4, output 'white LED
pin(33) = 0 'rote LED AUS
pin(4) = 1 'WHITE LED ON
pause 100
pin(4) = 0 'WHITE LED OFF
endif
gosub CAM_INIT
if (file.exists(FOTO$) = 0) and (PIC_NUM > 0) then
print CAMERA.PICTURE(FOTO$) ' save the first picture
endif
gosub MAIN
onHtmlReload setpage
gosub setpage
onHtmlChange paramchange
timer0 250, MAIN
wait
'#########################################################
'#########################################################
'#########################################################
MAIN:
'#########
if T$ <> time$ then
L = 1-L 'toggle for LED
T$ = time$
DateTime$ = "20" + date$(2) + "_" + T$
DateTime$ = replace$(DateTime$,"/","-")
'DateTime$ = replace$(DateTime$,":","-")
option.wdtreset
if flashfree < MIN_SPACE then
MAKE_FOTO_TXT$ = "NO SPACE FOR FOTO!"
MAKE_FOTO_COL$ = BUT_ON$
else
MAKE_FOTO_TXT$ = "make Foto"
MAKE_FOTO_COL$ = BUT_OFF$
endif
endif
IF SERVO_H_POS gosub SERVO_H_SET
IF SERVO_V_POS gosub SERVO_V_SET
'Set new resolution
IF RES_NEW$ <> RES$ goto "res_" + RES_NEW$
if BAS.DEVICE = ESP32CAM pin(33)=1-pin(33) 'toggles the red LED only at ESP32CAM
return
'############################################
setpage:
'#########
A$ = ""
A$ = A$ + |<table><td>| + textbox$(INFO$, "tbox_top") + textbox$(DateTime$, "tbox_top")
A$ = A$ + |<img id='camera' src="picture" alt="NO PIC from AnnexCam" style="width:112%;">|
A$ = A$ + |<br>|
IF SERVO_H_POS then A$ = A$ + slider$(SERVO_H_POS, SERVO_H_MIN, SERVO_H_MAX, "slider_hor" )
A$ = A$ + |<td>|
IF SERVO_V_POS then A$ = A$ + slider$(SERVO_V_POS, SERVO_V_MIN, SERVO_V_MAX, "slider_vert" )
A$ = A$ + |</table>|
A$ = A$ + "<center>"
'''''A$ = A$ + textbox$(INFO$, "tbox") + LED$(L)+ textbox$(DateTime$, "tbox") + "<br>"
if BAS.DEVICE = ESP32CAM then A$ = A$ + button$(LED_ON_OFF_TXT$, LED_ON_OFF, LED_ON_OFF_COL$)+ " "
A$ = A$ + listbox$(RES_NEW$,"320x240,640x480,800x600,1024x768,1280x1024,1600x1200","lbox")
A$ = A$ + button$(SHOW_PARAMS_TXT$, SHOW_PARAMS_ON_OFF, SHOW_PARAMS_COL$)+ " "
A$ = A$ + button$(MAKE_FOTO_TXT$, MAKE_FOTO, MAKE_FOTO_COL$) + " "
IF SERVO_H_POS then A$ = A$ + button$(AUTO_PAN_TXT$, AUTO_PAN, AUTO_PAN_COL$)
A$ = A$ + "<br></center>"
if SHOW_PARAMS = 1 then
A$ = A$ + |<center><table>|
'''A$ = A$ + slider$(quality, 10, 63, "slider") + "<td>jpg-Compression"
A$ = A$ + |<tr><td>|
A$ = A$ + slider$(brightness, -2, 2, "slider2") + "<td>Brightness"
A$ = A$ + |<tr><td>|
A$ = A$ + slider$(contrast, -2, 2, "slider") + "<td>Contrast"
A$ = A$ + |<tr><td>|
A$ = A$ + slider$(saturation, -2, 2, "slider2") + "<td>Saturation"
A$ = A$ + |<tr><td>|
'A$ = A$ + checkbox$(awb) + "AWB"
'A$ = A$ + |<tr><td>|
'A$ = A$ + checkbox$(awb_gain) + "<td>AWB gain"
' this is a checkbox using the variable 'awb_gain' (data-var='awb_gain')
A$ = A$ + |<div class="onoffswitch">|
A$ = A$ + | <input type="checkbox" data-var='awb_gain' onchange='cmdChange(event)' class="onoffswitch-checkbox" id="mycheck1" checked>|
A$ = A$ + | <label class="onoffswitch-label" for="mycheck1">|
A$ = A$ + | <span class="onoffswitch-inner"></span>|
A$ = A$ + | <span class="onoffswitch-switch"></span>|
A$ = A$ + | </label>|
A$ = A$ + |</div>|
A$ = A$ + |<td> AWB gain|
A$ = A$ + |<tr><td>|
A$ = A$ + slider$(wb_mode, 0, 4, "slider") + "<td>WB mode"
A$ = A$ + |<tr><td>|
'A$ = A$ + checkbox$(aec) + "AEC "
'A$ = A$ + |<tr><td>|
A$ = A$ + slider$(ae_level, -2, 2, "slider2" ) + "<td>AE Level"
A$ = A$ + |<tr><td>|
' this is a checkbox using the variable 'aec2' (data-var='aec2')
A$ = A$ + |<div class="onoffswitch">|
A$ = A$ + | <input type="checkbox" data-var='aec2' onchange='cmdChange(event)' class="onoffswitch-checkbox" id="mycheck2" checked>|
A$ = A$ + | <label class="onoffswitch-label" for="mycheck2">|
A$ = A$ + | <span class="onoffswitch-inner2"></span>|
A$ = A$ + | <span class="onoffswitch-switch"></span>|
A$ = A$ + | </label>|
A$ = A$ + |</div>|
A$ = A$ + |<td> AEC 2|
A$ = A$ + |</table><center>|
A$ = A$ + |</center></table></center>|
else
' Compute links to stored fotos. The fotos open in a NEW window
' This prevents from opening to many files (max 5!) on the CAM-webserver at the same time
A$ = A$ + |<center>SHOW <a href="/images/PIC_1.jpg" target="_blank"> THE LATEST FOTO </a> IN A NEW WINDOW </center>|
A$ = A$ + |<center>SHOW |
for I = 1 to PIC_NUM
A$ = A$ + | <a href="/images/PIC_|+ str$(I) +|.jpg?dummy=|+ MAKE_UNIQUE$ + |" target="_blank"> PIC_|+ str$(I) +| , </a> |
next I
if flashfree < MIN_SPACE then
A$ = A$ + "<br>"+ button$("DELETE ALL FOTOS", DELETE_FOTOS, BUT_ON$) + "<br>"
endif
endif
cls
html a$
a$ = ""
autorefresh 1000
jscall "set_pictimer(" + str$(ms_per_pic) + ");"
a$ = ""
CSS cssid$("slider_vert", "position: relative;right: 130%;transform:rotate(-90deg);height:80px;width:400%; ")
CSS cssid$("slider_hor", "position: relative; top: -30px;height:20px;width:110%; ")
CSS cssid$(BUT_ON$, "background-color:red;;height:50px;")
CSS cssid$(BUT_OFF$, "background-color:#44c767;height:50px;")
CSS cssid$("butyellow", "background-color:#ffec64;height:50px;")
CSS cssid$("tbox","text-align: center; background-color:lightgrey;")
CSS cssid$("tbox_top","text-align: left;position: relative;top: 30px; background-color:lightgrey;opacity: 0.3;")
CSS cssid$("lbox", "background-color:#44c767;;height:50px;")
return
'############################################
DELETE_FOTOS:
'#########
D$ = FILE.DIR$("/images/*.jpg")
While D$ <> ""
r = file.delete("/images/"+ D$)
D$ = FILE.DIR$
Wend
gosub setpage
return
'############################################
AUTO_PAN:
'#########
'Toggle AUTO_PAN ON or OFF
if AUTO_PAN_TXT$ = "AUTO_PAN=OFF" then
AUTO_PAN_TXT$ = "AUTO_PAN=ON"
AUTO_PAN_COL$ = BUT_ON$
gosub AUTO_PAN_STEP
timer1 4000,AUTO_PAN_STEP 'more auto-panning each 4 seconds
else
AUTO_PAN_TXT$ = "AUTO_PAN=OFF"
AUTO_PAN_COL$ = BUT_OFF$
timer1 0 'stopp auto-panning
endif
gosub setpage
return
'############################################
AUTO_PAN_STEP:
'#########
'slowly pan the camera completely and endlessly
' as this is called every 3 seconds by timer1
if SERVO_H_POS> SERVO_H_MAX then HSTEP = 0-abs(HSTEP)
if SERVO_H_POS< SERVO_H_MIN then HSTEP = abs(HSTEP)
SERVO_H_POS = SERVO_H_POS + HSTEP
return
'############################################
LED_ON_OFF:
'#########
pin(4)=1-pin(4)
if pin(4)= 0 then
LED_ON_OFF_COL$ = BUT_OFF$
LED_ON_OFF_TXT$ = "LED=OFF"
else
LED_ON_OFF_COL$ = "butyellow"
LED_ON_OFF_TXT$ = "LED=ON"
endif
gosub setpage
return
'############################################
MAKE_FOTO:
'#########
IF not FOTO_LOCKED then 'Prevent a parallel second start
FOTO_LOCKED = 1
if flashfree > MIN_SPACE then
MAKE_FOTO_TXT$ = "--WAIT---"
MAKE_FOTO_COL$ = BUT_ON$
ms_per_pic_old = ms_per_pic
ms_per_pic = 3000 'slow down live frame rate to give time to take and save the full pic
gosub setpage
pause 500
FOTO$ = "/images/PIC_"+STR$(PIC_NUM+1)+".jpg"
if file.exists(FOTO$) then print file.delete(FOTO$)
for I = (PIC_NUM) to 1 step -1
print file.rename("/images/PIC_"+STR$(I)+".jpg","/images/PIC_"+STR$(I+1)+".jpg")
next I
FOTO$ = "/images/PIC_1.JPG"
print CAMERA.PICTURE(FOTO$) ' ###### save the picture ####
MAKE_FOTO_TXT$ = "make FOTO"
MAKE_FOTO_COL$ = BUT_OFF$
else
MAKE_FOTO_TXT$ = "NO SPACE FOR FOTO!"
MAKE_FOTO_COL$ = BUT_ON$
gosub setpage
endif
MAKE_UNIQUE$ = str$(millis)
ms_per_pic = ms_per_pic_old
gosub setpage
FOTO_LOCKED = 0
endif
return
'############################################
SHOW_PARAMS_ON_OFF:
'#########
SHOW_PARAMS = 1-SHOW_PARAMS
IF SHOW_PARAMS = 0 then
SHOW_PARAMS_COL$ = BUT_OFF$
SHOW_PARAMS_TXT$ = "SETTINGS=OFF"
else
SHOW_PARAMS_COL$ = BUT_ON$
SHOW_PARAMS_TXT$ = "SETTINGS=ON"
endif
gosub setpage
refresh
return
'############################################
res_320x240:
'#########
print camera.params("framesize", 4)
RES$="320x240"
return
'############################################
res_640x480:
'#########
print camera.params("framesize", 6)
RES$="640x480"
gosub setpage
return
'############################################
res_800x600:
'#########
print camera.params("framesize", 7)
RES$="800x600"
gosub setpage
return
'############################################
res_1024x768:
'#########
print camera.params("framesize", 8)
RES$="1024x768"
gosub setpage
return
'############################################
res_1280x1024:
'#########
print camera.params("framesize", 9)
RES$="1280x1024"
gosub setpage
return
'############################################
res_1600x1200:
'#########
print camera.params("framesize", 10)
RES$="1600x1200"
gosub setpage
return
'############################################
v_flip:
'#########
v = camera.getvalue("vflip")
print camera.params("vflip", 1 - v)
return
'############################################
h_mirror:
'#########
v = camera.getvalue("hmirror")
print camera.params("hmirror", 1 - v)
return
'############################################
paramchange:
'#########
V$=""
if VX$ <> "X" print HtmlEventVar$
if VX$ <> "X" v$ = HtmlEventVar$
if (vx$ = "X") or (v$ = "quality") then print camera.params("quality", quality)
if (vx$ = "X") or (v$ = "brightness") then print camera.params("brightness", brightness)
if (vx$ = "X") or (v$ = "contrast") then print camera.params("contrast", contrast)
if (vx$ = "X") or (v$ = "saturation") then print camera.params("saturation", saturation)
if (vx$ = "X") or (v$ = "awb") then print camera.params("awb", awb)
if (vx$ = "X") or (v$ = "awb_gain") then print camera.params("awb_gain", awb_gain)
if (vx$ = "X") or (v$ = "wb_mode") then print camera.params("wb_mode", wb_mode)
if (vx$ = "X") or (v$ = "aec") then print camera.params("aec", aec)
if (vx$ = "X") or (v$ = "aec2") then print camera.params("aec2", aec2)
if (vx$ = "X") or (v$ = "ae_level") then print camera.params("ae_level", ae_level)
if (vx$ = "X") or (v$ = "agc") then print camera.params("agc", agc)
if (vx$ = "X") or (v$ = "agc_gain") then print camera.params("agc_gain", agc_gain)
if (vx$ = "X") or (v$ = "gainceiling") then print camera.params("gainceiling", gainceiling)
vx$ = ""
return
'######################################################################
SERVO_H_INIT:
'Horizontal servo -------------------------------
S_H = SERVO_H_POS
if BAS.DEVICE = ESP32CAM SERVO_H_PIN = 12 'FOR ESP32CAM
if BAS.DEVICE = M5CAM SERVO_H_PIN = 13 'FOR M5CAM only
SERVO_H_CHAN = 7
PWM.SETUP SERVO_H_PIN, SERVO_H_CHAN, SERVO_H_POS, 50, 12
GOSUB SERVO_H_SET
pause 1000
return
'######################################################################
SERVO_V_INIT:
'Vertical servo -------------------------------
S_V = SERVO_V_POS
if BAS.DEVICE = ESP32CAM SERVO_V_PIN = 3 '= RX-Pin!!!! '<<<<<<<<<<<<<<<
if BAS.DEVICE = M5CAM SERVO_V_PIN = 4 'FOR M5CAM only
SERVO_V_CHAN = 8
PWM.SETUP SERVO_V_PIN, SERVO_V_CHAN, SERVO_V_POS, 50, 12
GOSUB SERVO_V_SET
return
'############################################
SERVO_H_SET:
'#########
if abs(S_H - SERVO_H_POS)> 4 then
S_H = int( S_H+((SERVO_H_POS - S_H )/10))
if SERVO_H_FLIP then
PWM.OUT SERVO_H_CHAN, (600-S_H) '<<<<<<<<<<horizontal servo inverted
else
PWM.OUT SERVO_H_CHAN, (S_H) '<<<<<<<<<<horizontal servo normal
endif
endif
return
'############################################
SERVO_V_SET:
'#########
if abs (S_V - SERVO_V_POS) > 4 then
S_V = int( S_V+((SERVO_V_POS - S_V )/10))
if SERVO_V_FLIP then
PWM.OUT SERVO_V_CHAN, (600 - S_V) '<<<<<<<<<<vertical servo inverted
else
PWM.OUT SERVO_V_CHAN, (S_V) '<<<<<<<<<<vertical servo normal
endif
endif
return
'############################################
CAM_INIT:
'#########
' variables with CAM parameters
quality = 10 : brightness = 0
contrast = 0 : saturation = 0
awb = 1 : awb_gain = 1
wb_mode = 0 : aec = 0
aec2 = 1 : ae_level = 0
agc = 0 : agc_gain = 0
gainceiling = 0
vx$ ="X" ' "X" = change all parameters
gosub paramchange ' initialize camera with this parameters
'set CAM-ORIENTATION
print camera.params("vflip", PIC_V_FLIP)
print camera.params("hmirror",PIC_H_MIRROR)
'set initial CAM-resolution '<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
'gosub res_800x600
gosub res_1024x768
'gosub res_1280x1024
RES_NEW$ = RES$
if file.exists(FOTO$) = 0 then print CAMERA.PICTURE(FOTO$) ' save the first picture
return