Skip to content

Commit 25ac42a

Browse files
authored
Switch for camera control added (automatic / manual)
- Switching between automatic and manual camera control made possible via different endpoints - All cameras will be reset to automatic control at a specified reset time (set in the configuration file) - Edited README
2 parents 6b6d23b + 02b0e5a commit 25ac42a

File tree

6 files changed

+195
-16
lines changed

6 files changed

+195
-16
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,14 @@ agent_calendar_update_time{agent="test_agent"} 1.707571943100096e+09
133133
# TYPE camera_position gauge
134134
camera_position{camera="http://camera-2-panasonic.example.com"} 10.0
135135
```
136+
137+
## Endpoints for switchting and checking the camera control status
138+
139+
The camera control status of a specific camera can be changed as follows:
140+
- Accessing the endpoint `/control/automatic/<camera_url>` sets the control status of the given camera to 'automatic'.
141+
- Accessing the endpoint `/control/manual/<camera_url>` sets the control status of the given camera to 'manual'.
142+
The placeholder <camera_url> must be replaced by the actual camera identifier, i.e. `control/automatic/cameraXY.example.de`. The default control status is 'automatic'.
143+
144+
The current control status of a specific camera can be requested by calling the endpoint `/control_status/<camera_url>`. The placeholder <camera_url> must be replaced by the actual camera identifier, i.e. `control/automatic/cameraXY.example.de`.
145+
146+
At 03:00 am, all cameras will be reset to automatic control. You may adjust the reset time in your configuration file by changing the variable `reset_time`. For instance, you could set the variable to `reset_time: "15:00"` to reset to automatic control at 3 pm.

camera-control.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ opencast:
1111
# Password of the specified Opencast user
1212
password: opencast
1313

14+
basic_auth:
15+
username: USER_NAME
16+
password: CHANGE_ME
17+
1418
calendar:
1519
# The frequency in which the calendar should be updated in seconds
1620
# Default: 120
@@ -27,6 +31,11 @@ calendar:
2731
# Default: 300
2832
camera_update_frequency: 300
2933

34+
# The reset-time is used to reset the camera control status for every camera
35+
# to "automatic" at a certain time. The time is specified in the format HH:MM.
36+
# Default: "03:00"
37+
reset_time: "03:00"
38+
3039
# Camera Configuration
3140
# Configure the capture agents to get the calendar for and a list of cameras to
3241
# control when a capture agent starts.
@@ -39,6 +48,9 @@ camera_update_frequency: 300
3948
# password: password to access the API (optional)
4049
# preset_active: Camera preset to move to when recording (default: 1)
4150
# preset_inactive: Camera preset to move to when not recording (default: 10)
51+
# control: Flag for switching between automatic and manual camera control
52+
# (default: "automatic")
53+
4254
camera:
4355
test-agent:
4456
- url: http://camera-panasonic.example.com

occameracontrol/__main__.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1616

1717
import argparse
18+
import datetime
1819
import logging
1920
import sys
2021
import time
@@ -26,6 +27,8 @@
2627
from occameracontrol.camera import Camera
2728
from occameracontrol.metrics import start_metrics_exporter, RequestErrorHandler
2829

30+
from occameracontrol.camera_control_server import start_camera_control_server
31+
2932

3033
logger = logging.getLogger(__name__)
3134

@@ -48,16 +51,35 @@ def update_agents(agents: list[Agent]):
4851
time.sleep(update_frequency)
4952

5053

51-
def control_camera(camera: Camera):
52-
'''Control loop to trigger updating the camera position based on currently
54+
def control_camera(camera: Camera, reset_time: datetime.datetime):
55+
"""Control loop to trigger updating the camera position based on currently
5356
active events.
54-
'''
57+
param camera: Camera object to control
58+
param reset_time: datetime to reset control to automatic (default: 03:00)
59+
"""
60+
if reset_time is None:
61+
reset_time = datetime.datetime.combine(
62+
date=datetime.date.today(),
63+
time=datetime.time(3, 00, 00)
64+
)
5565
error_handler = RequestErrorHandler(
5666
camera.url,
5767
f'Failed to communicate with camera {camera}')
5868
while True:
5969
with error_handler:
60-
camera.update_position()
70+
if reset_time < datetime.datetime.now():
71+
logger.info(f'current time is {datetime.datetime.now()}, '
72+
f'the reset time is {reset_time}')
73+
logger.info(f'Reset {camera} to \'automatic\'')
74+
camera.control = "automatic"
75+
camera.position = -1
76+
reset_time = reset_time + datetime.timedelta(days=1)
77+
logger.info(f'Next reset time is set to {reset_time}')
78+
if camera.control == "automatic":
79+
camera.update_position()
80+
else:
81+
camera.activate_camera()
82+
camera.check_calendar()
6183
time.sleep(1)
6284

6385

@@ -84,6 +106,11 @@ def main():
84106

85107
cameras = []
86108
agents = []
109+
reset_time = datetime.datetime.combine(
110+
date=datetime.date.today(),
111+
time=datetime.time.fromisoformat(config_rt(str, 'reset_time'))
112+
)
113+
logger.info('reset time is set to %s', reset_time)
87114
for agent_id, agent_cameras in config_rt(dict, 'camera').items():
88115
agent = Agent(agent_id)
89116
agent.verify_agent()
@@ -100,14 +127,21 @@ def main():
100127
agent_update.start()
101128

102129
for camera in cameras:
103-
logger.info('Starting camera control for %s', camera)
104-
control_thread = Thread(target=control_camera, args=(camera,))
130+
logger.info('Starting camera control for %s with control status %s',
131+
camera, getattr(camera, 'control'))
132+
control_thread = Thread(target=control_camera,
133+
args=(camera, reset_time))
105134
threads.append(control_thread)
106135
control_thread.start()
107136

108137
# Start delivering metrics
109138
start_metrics_exporter()
110139

140+
# Start camera control server
141+
auth = (config_rt(str, 'basic_auth', 'username'),
142+
config_rt(str, 'basic_auth', 'password'))
143+
start_camera_control_server(cameras=cameras, auth=auth)
144+
111145
try:
112146
for thread in threads:
113147
thread.join()

occameracontrol/camera.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ class Camera:
5252
preset_inactive: int = 10
5353
last_updated: float = 0.0
5454
update_frequency: int = 300
55+
# Flag for switching between automatic and manual camera control
56+
# automatic = The corresponding camera will be controlled automatically,
57+
# i.e. the camera position will be adjusted
58+
# according to the agent's state and the values given
59+
# in preset_active and preset_inactive
60+
# manual = The corresponding camera will be controlled manually.
61+
# Values in preset_active and preset_inactive will
62+
# be ignored as well as the agent's status
63+
control: str = "automatic"
5564

5665
def __init__(self,
5766
agent: Agent,
@@ -60,7 +69,8 @@ def __init__(self,
6069
user: Optional[str] = None,
6170
password: Optional[str] = None,
6271
preset_active: int = 1,
63-
preset_inactive: int = 10):
72+
preset_inactive: int = 10,
73+
control: str = "automatic"):
6474
self.agent = agent
6575
self.url = url.rstrip('/')
6676
self.type = CameraType[type]
@@ -69,6 +79,7 @@ def __init__(self,
6979
self.preset_active = preset_active
7080
self.preset_inactive = preset_inactive
7181
self.update_frequency = config_t(int, 'camera_update_frequency') or 300
82+
self.control = control
7283

7384
def __str__(self) -> str:
7485
'''Returns a string representation of this camera
@@ -107,6 +118,7 @@ def activate_camera(self, on=True):
107118
def move_to_preset(self, preset: int):
108119
'''Move the PTZ camera to the specified preset position
109120
'''
121+
self.activate_camera()
110122
register_camera_expectation(self.url, preset)
111123
if self.type == CameraType.panasonic:
112124
params = {'cmd': f'#R{preset - 1:02}', 'res': 1}
@@ -142,11 +154,7 @@ def from_now(self, ts: float) -> str:
142154
seconds = int(ts - time.time()) # seconds are enough accuracy
143155
return str(datetime.timedelta(seconds=seconds))
144156

145-
def update_position(self):
146-
'''Check for currently active events with the camera's capture agent
147-
and move the camera to the appropriate (active, inactive) position if
148-
necessary.
149-
'''
157+
def check_calendar(self):
150158
agent_id = self.agent.agent_id
151159
level = logging.DEBUG if int(time.time()) % 60 else logging.INFO
152160

@@ -155,7 +163,6 @@ def update_position(self):
155163
time.sleep(1)
156164

157165
event = self.agent.next_event()
158-
159166
if event.future():
160167
logger.log(level, '[%s] Next event `%s` starts in %s',
161168
agent_id, event.title[:40], self.from_now(event.start))
@@ -165,22 +172,28 @@ def update_position(self):
165172
else:
166173
logger.log(level, '[%s] No planned events', agent_id)
167174

175+
return event
176+
177+
def update_position(self):
178+
'''Check for currently active events with the camera's capture agent
179+
and move the camera to the appropriate (active, inactive) position if
180+
necessary.
181+
'''
182+
agent_id = self.agent.agent_id
183+
event = self.check_calendar()
168184
if event.active(): # active event
169185
if self.position != self.preset_active:
170186
logger.info('[%s] Event `%s` started', agent_id, event.title)
171187
logger.info('[%s] Moving to preset %i', agent_id,
172188
self.preset_active)
173-
self.activate_camera()
174189
self.move_to_preset(self.preset_active)
175190
else: # No active event
176191
if self.position != self.preset_inactive:
177192
logger.info('[%s] Returning to preset %i', agent_id,
178193
self.preset_inactive)
179-
self.activate_camera()
180194
self.move_to_preset(self.preset_inactive)
181195

182196
if time.time() - self.last_updated >= self.update_frequency:
183197
logger.info('[%s] Re-sending preset %i to camera', agent_id,
184198
self.position)
185-
self.activate_camera()
186199
self.move_to_preset(self.position)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Opencast Camera Control
2+
# Copyright 2024 Osnabrück University, virtUOS
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
import logging
18+
19+
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
20+
from flask import Flask, Response
21+
from flask_basicauth import BasicAuth
22+
23+
logger = logging.getLogger(__name__)
24+
25+
app = Flask(__name__)
26+
basic_auth = BasicAuth(app)
27+
28+
29+
@app.route('/control/<string:status>/<string:req_camera_url>')
30+
@basic_auth.required
31+
def activate_camera(status, req_camera_url):
32+
""" Endpoint for switching between manual and automatic camera control.
33+
The desired camera is identified by the passed req_camera_url.
34+
"""
35+
if status == "manual":
36+
logger.info('Camera with URL "[%s]" will be controlled manually and ' +
37+
'therefore it\'s position won\'t be updated automatically',
38+
req_camera_url)
39+
elif status == "automatic":
40+
logger.info('Camera with URL "[%s]" will switch to automatic ' +
41+
'controlling. The camera\'s position will be ' +
42+
'adjusted to the corresponding agent\'s status ' +
43+
'automatically.', req_camera_url)
44+
else:
45+
return 'ERROR<br/>Given status %s is invalid.', status
46+
47+
# Get rid of http or https prefixes to ensure reliable comparability
48+
sanitized_camera_url = (req_camera_url.replace('http://', '')
49+
.replace('https://', ''))
50+
cameras = app.config["cameras"]
51+
for camera in cameras:
52+
camera_url = (getattr(camera, 'url').replace('http://', '')
53+
.replace('https://', ''))
54+
if sanitized_camera_url == camera_url:
55+
setattr(camera, 'control', status)
56+
# Resets the current position of the camera
57+
setattr(camera, 'position', -1)
58+
return (
59+
f"Successfully set camera with url '{camera_url} "
60+
f"to control status <b>'{status}'</b>."
61+
)
62+
logger.info(f"Camera with url '{req_camera_url}' could not be found.")
63+
return f"ERROR<br/>Camera with url '{req_camera_url}' could not be found."
64+
65+
66+
@app.route('/control_status/<string:req_camera_url>')
67+
@basic_auth.required
68+
def view_current_camera_control_status(req_camera_url):
69+
""" Endpoint for requesting the current control status
70+
(manual or automatic) for a camera.
71+
The desired camera is identified by the passed camera url.
72+
"""
73+
# Get rid of http or https prefixes to ensure reliable comparability
74+
sanitized_camera_url = (req_camera_url.replace('http://', '')
75+
.replace('https://', ''))
76+
cameras = app.config["cameras"]
77+
for camera in cameras:
78+
camera_url = (getattr(camera, 'url').replace('http://', '')
79+
.replace('https://', ''))
80+
if sanitized_camera_url == camera_url:
81+
return (
82+
f"STATUS<br/>The control status of the camera "
83+
f"with url '{req_camera_url}' is "
84+
f"<b>{getattr(camera, 'control')}</b>"
85+
)
86+
logger.info(f"Camera with url '{req_camera_url}' could not be found.")
87+
return f"ERROR</br>Camera with url '{req_camera_url}' could not be found."
88+
89+
90+
# expose camera control metrics
91+
@app.route('/metrics')
92+
def metrics():
93+
""" Endpoint for exposing the camera control metrics.
94+
"""
95+
return Response(generate_latest(), content_type=CONTENT_TYPE_LATEST)
96+
97+
98+
def start_camera_control_server(cameras, auth: tuple[str, str]):
99+
"""Start the flask server for managing the camera control
100+
"""
101+
logger.info('Starting camera control server')
102+
# start flask app
103+
# ToDo get host and port from config, default 127.0.0.1:8000
104+
app.config['cameras'] = cameras
105+
app.config['BASIC_AUTH_USERNAME'] = auth[0]
106+
app.config['BASIC_AUTH_PASSWORD'] = auth[1]
107+
app.run(host='127.0.0.1', port=8080)

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ confygure >= 0.1.0
22
prometheus-client >= 0.13.1
33
python-dateutil
44
requests
5+
flask >= 3.1.0
6+
flask-basicauth >= 0.2.0

0 commit comments

Comments
 (0)