Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,14 @@ agent_calendar_update_time{agent="test_agent"} 1.707571943100096e+09
# TYPE camera_position gauge
camera_position{camera="http://camera-2-panasonic.example.com"} 10.0
```

## Endpoints for switchting and checking the camera control status

The camera control status of a specific camera can be changed as follows:
- Accessing the endpoint `/control/automatic/<camera_url>` sets the control status of the given camera to 'automatic'.
- Accessing the endpoint `/control/manual/<camera_url>` sets the control status of the given camera to 'manual'.
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'.

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`.

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.
12 changes: 12 additions & 0 deletions camera-control.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ opencast:
# Password of the specified Opencast user
password: opencast

basic_auth:
username: USER_NAME
password: CHANGE_ME

calendar:
# The frequency in which the calendar should be updated in seconds
# Default: 120
Expand All @@ -27,6 +31,11 @@ calendar:
# Default: 300
camera_update_frequency: 300

# The reset-time is used to reset the camera control status for every camera
# to "automatic" at a certain time. The time is specified in the format HH:MM.
# Default: "03:00"
reset_time: "03:00"

# Camera Configuration
# Configure the capture agents to get the calendar for and a list of cameras to
# control when a capture agent starts.
Expand All @@ -39,6 +48,9 @@ camera_update_frequency: 300
# password: password to access the API (optional)
# preset_active: Camera preset to move to when recording (default: 1)
# preset_inactive: Camera preset to move to when not recording (default: 10)
# control: Flag for switching between automatic and manual camera control
# (default: "automatic")

camera:
test-agent:
- url: http://camera-panasonic.example.com
Expand Down
46 changes: 40 additions & 6 deletions occameracontrol/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.

import argparse
import datetime
import logging
import sys
import time
Expand All @@ -26,6 +27,8 @@
from occameracontrol.camera import Camera
from occameracontrol.metrics import start_metrics_exporter, RequestErrorHandler

from occameracontrol.camera_control_server import start_camera_control_server


logger = logging.getLogger(__name__)

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


def control_camera(camera: Camera):
'''Control loop to trigger updating the camera position based on currently
def control_camera(camera: Camera, reset_time: datetime.datetime):
"""Control loop to trigger updating the camera position based on currently
active events.
'''
param camera: Camera object to control
param reset_time: datetime to reset control to automatic (default: 03:00)
"""
if reset_time is None:
reset_time = datetime.datetime.combine(
date=datetime.date.today(),
time=datetime.time(3, 00, 00)
)
error_handler = RequestErrorHandler(
camera.url,
f'Failed to communicate with camera {camera}')
while True:
with error_handler:
camera.update_position()
if reset_time < datetime.datetime.now():
logger.info(f'current time is {datetime.datetime.now()}, '
f'the reset time is {reset_time}')
logger.info(f'Reset {camera} to \'automatic\'')
camera.control = "automatic"
camera.position = -1
reset_time = reset_time + datetime.timedelta(days=1)
logger.info(f'Next reset time is set to {reset_time}')
if camera.control == "automatic":
camera.update_position()
else:
camera.activate_camera()
camera.check_calendar()
time.sleep(1)


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

cameras = []
agents = []
reset_time = datetime.datetime.combine(
date=datetime.date.today(),
time=datetime.time.fromisoformat(config_rt(str, 'reset_time'))
)
logger.info('reset time is set to %s', reset_time)
for agent_id, agent_cameras in config_rt(dict, 'camera').items():
agent = Agent(agent_id)
agent.verify_agent()
Expand All @@ -100,14 +127,21 @@ def main():
agent_update.start()

for camera in cameras:
logger.info('Starting camera control for %s', camera)
control_thread = Thread(target=control_camera, args=(camera,))
logger.info('Starting camera control for %s with control status %s',
camera, getattr(camera, 'control'))
control_thread = Thread(target=control_camera,
args=(camera, reset_time))
threads.append(control_thread)
control_thread.start()

# Start delivering metrics
start_metrics_exporter()

# Start camera control server
auth = (config_rt(str, 'basic_auth', 'username'),
config_rt(str, 'basic_auth', 'password'))
start_camera_control_server(cameras=cameras, auth=auth)

try:
for thread in threads:
thread.join()
Expand Down
33 changes: 23 additions & 10 deletions occameracontrol/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ class Camera:
preset_inactive: int = 10
last_updated: float = 0.0
update_frequency: int = 300
# Flag for switching between automatic and manual camera control
# automatic = The corresponding camera will be controlled automatically,
# i.e. the camera position will be adjusted
# according to the agent's state and the values given
# in preset_active and preset_inactive
# manual = The corresponding camera will be controlled manually.
# Values in preset_active and preset_inactive will
# be ignored as well as the agent's status
control: str = "automatic"

def __init__(self,
agent: Agent,
Expand All @@ -60,7 +69,8 @@ def __init__(self,
user: Optional[str] = None,
password: Optional[str] = None,
preset_active: int = 1,
preset_inactive: int = 10):
preset_inactive: int = 10,
control: str = "automatic"):
self.agent = agent
self.url = url.rstrip('/')
self.type = CameraType[type]
Expand All @@ -69,6 +79,7 @@ def __init__(self,
self.preset_active = preset_active
self.preset_inactive = preset_inactive
self.update_frequency = config_t(int, 'camera_update_frequency') or 300
self.control = control

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

def update_position(self):
'''Check for currently active events with the camera's capture agent
and move the camera to the appropriate (active, inactive) position if
necessary.
'''
def check_calendar(self):
agent_id = self.agent.agent_id
level = logging.DEBUG if int(time.time()) % 60 else logging.INFO

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

event = self.agent.next_event()

if event.future():
logger.log(level, '[%s] Next event `%s` starts in %s',
agent_id, event.title[:40], self.from_now(event.start))
Expand All @@ -165,22 +172,28 @@ def update_position(self):
else:
logger.log(level, '[%s] No planned events', agent_id)

return event

def update_position(self):
'''Check for currently active events with the camera's capture agent
and move the camera to the appropriate (active, inactive) position if
necessary.
'''
agent_id = self.agent.agent_id
event = self.check_calendar()
if event.active(): # active event
if self.position != self.preset_active:
logger.info('[%s] Event `%s` started', agent_id, event.title)
logger.info('[%s] Moving to preset %i', agent_id,
self.preset_active)
self.activate_camera()
self.move_to_preset(self.preset_active)
else: # No active event
if self.position != self.preset_inactive:
logger.info('[%s] Returning to preset %i', agent_id,
self.preset_inactive)
self.activate_camera()
self.move_to_preset(self.preset_inactive)

if time.time() - self.last_updated >= self.update_frequency:
logger.info('[%s] Re-sending preset %i to camera', agent_id,
self.position)
self.activate_camera()
self.move_to_preset(self.position)
107 changes: 107 additions & 0 deletions occameracontrol/camera_control_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Opencast Camera Control
# Copyright 2024 Osnabrück University, virtUOS
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

import logging

from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from flask import Flask, Response
from flask_basicauth import BasicAuth

logger = logging.getLogger(__name__)

app = Flask(__name__)
basic_auth = BasicAuth(app)


@app.route('/control/<string:status>/<string:req_camera_url>')
@basic_auth.required
def activate_camera(status, req_camera_url):
""" Endpoint for switching between manual and automatic camera control.
The desired camera is identified by the passed req_camera_url.
"""
if status == "manual":
logger.info('Camera with URL "[%s]" will be controlled manually and ' +
'therefore it\'s position won\'t be updated automatically',
req_camera_url)
elif status == "automatic":
logger.info('Camera with URL "[%s]" will switch to automatic ' +
'controlling. The camera\'s position will be ' +
'adjusted to the corresponding agent\'s status ' +
'automatically.', req_camera_url)
else:
return 'ERROR<br/>Given status %s is invalid.', status

# Get rid of http or https prefixes to ensure reliable comparability
sanitized_camera_url = (req_camera_url.replace('http://', '')
.replace('https://', ''))
cameras = app.config["cameras"]
for camera in cameras:
camera_url = (getattr(camera, 'url').replace('http://', '')
.replace('https://', ''))
if sanitized_camera_url == camera_url:
setattr(camera, 'control', status)
# Resets the current position of the camera
setattr(camera, 'position', -1)
return (
f"Successfully set camera with url '{camera_url} "
f"to control status <b>'{status}'</b>."
)
logger.info(f"Camera with url '{req_camera_url}' could not be found.")
return f"ERROR<br/>Camera with url '{req_camera_url}' could not be found."


@app.route('/control_status/<string:req_camera_url>')
@basic_auth.required
def view_current_camera_control_status(req_camera_url):
""" Endpoint for requesting the current control status
(manual or automatic) for a camera.
The desired camera is identified by the passed camera url.
"""
# Get rid of http or https prefixes to ensure reliable comparability
sanitized_camera_url = (req_camera_url.replace('http://', '')
.replace('https://', ''))
cameras = app.config["cameras"]
for camera in cameras:
camera_url = (getattr(camera, 'url').replace('http://', '')
.replace('https://', ''))
if sanitized_camera_url == camera_url:
return (
f"STATUS<br/>The control status of the camera "
f"with url '{req_camera_url}' is "
f"<b>{getattr(camera, 'control')}</b>"
)
logger.info(f"Camera with url '{req_camera_url}' could not be found.")
return f"ERROR</br>Camera with url '{req_camera_url}' could not be found."


# expose camera control metrics
@app.route('/metrics')
def metrics():
""" Endpoint for exposing the camera control metrics.
"""
return Response(generate_latest(), content_type=CONTENT_TYPE_LATEST)


def start_camera_control_server(cameras, auth: tuple[str, str]):
"""Start the flask server for managing the camera control
"""
logger.info('Starting camera control server')
# start flask app
# ToDo get host and port from config, default 127.0.0.1:8000
app.config['cameras'] = cameras
app.config['BASIC_AUTH_USERNAME'] = auth[0]
app.config['BASIC_AUTH_PASSWORD'] = auth[1]
app.run(host='127.0.0.1', port=8080)
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ confygure >= 0.1.0
prometheus-client >= 0.13.1
python-dateutil
requests
flask >= 3.1.0
flask-basicauth >= 0.2.0