Skip to content

Commit c064c38

Browse files
authored
feat: Refactor webcam snapshot handling and add new configuration options for notifications (#108)
1 parent 1d1e2eb commit c064c38

10 files changed

Lines changed: 522 additions & 192 deletions

File tree

mobileraker.py

Lines changed: 113 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1-
import argparse
21
import asyncio
2+
import argparse
33
import logging
44
import os
5+
import sys
6+
from asyncio import AbstractEventLoop
7+
58
from mobileraker.client.mobileraker_fcm_client import MobilerakerFcmClient
69
from mobileraker.client.moonraker_client import MoonrakerClient
7-
from mobileraker.client.snapshot_client import SnapshotClient
10+
from mobileraker.client.webcam_snapshot_client import WebcamSnapshotClient
811
from mobileraker.mobileraker_companion import MobilerakerCompanion
912
from mobileraker.service.data_sync_service import DataSyncService
1013
from mobileraker.util.configs import CompanionLocalConfig, printer_data_logs_dir
1114
from mobileraker.util.functions import get_software_version
1215
from mobileraker.util.logging import setup_logging
1316

1417

15-
def main() -> None:
16-
parser = argparse.ArgumentParser(
17-
description="Mobileraker - Companion")
18-
18+
# Main entry point
19+
async def main(args):
20+
# Parse arguments
21+
parser = argparse.ArgumentParser(description="MobilerakerCompanion - An app for push notifications for Klipper/Moonraker")
1922
parser.add_argument(
2023
"-l", "--logfile", default=os.path.join(printer_data_logs_dir if os.path.exists(printer_data_logs_dir) else '/tmp', "mobileraker.log"), metavar='<logfile>',
2124
help="Log File Location or log file absolute path")
@@ -26,64 +29,117 @@ def main() -> None:
2629
"-c", "--configfile", default="~/Mobileraker.conf", metavar='<configfile>',
2730
help="Location of the configuration file for Mobileraker Companion"
2831
)
29-
30-
cmd_line_args = parser.parse_args()
32+
33+
parsed_args = parser.parse_args(args)
3134

3235
version = get_software_version()
33-
if not cmd_line_args.nologfile:
34-
setup_logging(os.path.normpath(os.path.expanduser(
35-
cmd_line_args.logfile)), version)
3636

37+
# Setup logging
38+
if not parsed_args.nologfile:
39+
setup_logging(os.path.normpath(os.path.expanduser(
40+
parsed_args.logfile)), version)
41+
3742
logging.info(f"MobilerakerCompanion version: {version}")
38-
39-
passed_config_location = os.path.normpath(
40-
os.path.expanduser(cmd_line_args.configfile))
41-
42-
local_config = CompanionLocalConfig(passed_config_location)
43-
event_loop = asyncio.new_event_loop()
44-
asyncio.set_event_loop(event_loop)
45-
fcmc = MobilerakerFcmClient(
46-
# 'http://127.0.0.1:8080',
47-
'https://mobileraker.eliteschw31n.de',
48-
event_loop)
43+
44+
# Load the config
45+
config = CompanionLocalConfig(parsed_args.configfile)
46+
47+
# Get the event loop
48+
loop = asyncio.get_event_loop()
49+
4950
try:
50-
printers = local_config.printers
51-
for printer_name in printers:
52-
p_config = printers[printer_name]
53-
54-
jrpc = MoonrakerClient(
55-
p_config['moonraker_uri'],
56-
p_config['moonraker_api_key'],
57-
printer_name,
58-
event_loop)
59-
60-
snc = SnapshotClient(
61-
p_config['snapshot_uri'],
62-
p_config['snapshot_rotation'],
51+
# Create a task for each printer
52+
tasks = []
53+
for printer_name, printer_cfg in config.printers.items():
54+
task = loop.create_task(
55+
setup_printer_companion(
56+
printer_name,
57+
printer_cfg,
58+
config,
59+
loop
60+
)
6361
)
62+
tasks.append(task)
63+
64+
# Wait for all tasks to complete
65+
await asyncio.gather(*tasks)
66+
67+
except KeyboardInterrupt:
68+
logging.info("Received keyboard interrupt, shutting down...")
69+
except Exception as e:
70+
logging.exception(f"Unhandled exception: {e}")
71+
finally:
72+
# Close the event loop
73+
loop.close()
6474

65-
dsd = DataSyncService(
66-
jrpc=jrpc,
67-
printer_name=printer_name,
68-
loop=event_loop,
69-
)
7075

71-
client = MobilerakerCompanion(
72-
jrpc=jrpc,
73-
data_sync_service=dsd,
74-
fcm_client=fcmc,
75-
snapshot_client=snc,
76-
printer_name=printer_name,
77-
loop=event_loop,
78-
companion_config=local_config,
79-
exclude_sensors=p_config['excluded_filament_sensors'],
80-
)
81-
event_loop.create_task(client.start())
82-
event_loop.run_forever()
83-
finally:
84-
event_loop.close()
85-
exit()
76+
async def setup_printer_companion(
77+
printer_name: str,
78+
printer_cfg: dict,
79+
companion_config: CompanionLocalConfig,
80+
loop: AbstractEventLoop
81+
):
82+
"""
83+
Set up the MobilerakerCompanion for a specific printer.
84+
85+
Args:
86+
printer_name (str): The name of the printer.
87+
printer_cfg (dict): The printer configuration.
88+
companion_config (CompanionLocalConfig): The companion configuration.
89+
loop (AbstractEventLoop): The event loop.
90+
"""
91+
moonraker_uri = printer_cfg["moonraker_uri"]
92+
moonraker_api_key = printer_cfg["moonraker_api_key"]
93+
snapshot_uri = printer_cfg["snapshot_uri"]
94+
snapshot_rotation = printer_cfg["snapshot_rotation"]
95+
exclude_sensors = printer_cfg["excluded_filament_sensors"]
96+
97+
# Create the JRPC client
98+
jrpc = MoonrakerClient(
99+
moonraker_uri=moonraker_uri,
100+
moonraker_api=moonraker_api_key,
101+
printer_name=printer_name,
102+
loop=loop
103+
)
104+
105+
# Create the data sync service
106+
data_sync_service = DataSyncService(
107+
jrpc=jrpc,
108+
printer_name=printer_name,
109+
loop=loop
110+
)
111+
112+
# Create the FCM client
113+
fcm_client = MobilerakerFcmClient(
114+
# 'http://127.0.0.1:8080',
115+
'https://mobileraker.eliteschw31n.de',
116+
loop)
117+
118+
# Create the default snapshot client (will be used as fallback)
119+
snapshot_client = WebcamSnapshotClient(
120+
uri_or_data=snapshot_uri,
121+
rotation=snapshot_rotation
122+
)
123+
124+
# Create the MobilerakerCompanion
125+
companion = MobilerakerCompanion(
126+
jrpc=jrpc,
127+
data_sync_service=data_sync_service,
128+
fcm_client=fcm_client,
129+
webcam_snapshot_client=snapshot_client,
130+
printer_name=printer_name,
131+
loop=loop,
132+
companion_config=companion_config,
133+
exclude_sensors=exclude_sensors
134+
)
135+
136+
logging.info("Starting MobilerakerCompanion for printer: %s", printer_name)
137+
await companion.start()
138+
139+
# Keep the task running
140+
while True:
141+
await asyncio.sleep(3600) # Sleep for an hour and check again
86142

87143

88-
if __name__ == '__main__':
89-
main()
144+
if __name__ == "__main__":
145+
asyncio.run(main(sys.argv[1:]))

mobileraker/client/snapshot_client.py

Lines changed: 0 additions & 65 deletions
This file was deleted.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from io import BytesIO
2+
import logging
3+
from typing import Optional, Union
4+
5+
from PIL import Image, ImageOps
6+
import requests
7+
8+
from mobileraker.data.dtos.moonraker.webcam_data import WebcamData
9+
10+
11+
12+
class WebcamSnapshotClient:
13+
"""
14+
A client that captures and processes snapshots from a webcam.
15+
16+
Parameters:
17+
uri_or_data (Union[str, WebcamData]): Either a URI string or WebcamData object.
18+
base_url (str, optional): Base URL of the server to prepend for relative paths. Default is "http://localhost".
19+
rotation (int, optional): Fallback rotation angle if URI is provided directly. Default is 0.
20+
21+
Attributes:
22+
uri (str): The URI to fetch the snapshot from.
23+
base_url (str): Base URL of the server for handling relative paths.
24+
rotation (int): The rotation angle (in degrees) to apply to the captured snapshot.
25+
flip_horizontal (bool): Whether to flip the image horizontally.
26+
flip_vertical (bool): Whether to flip the image vertically.
27+
logger (logging.Logger): The logger instance for logging messages.
28+
"""
29+
30+
def __init__(self, uri_or_data: Union[str, WebcamData], base_url: str = "http://localhost", rotation: int = 0) -> None:
31+
self.base_url = base_url.rstrip('/')
32+
33+
if isinstance(uri_or_data, WebcamData):
34+
self.uri = self._normalize_uri(uri_or_data.snapshot_url)
35+
self.rotation = uri_or_data.rotation
36+
self.flip_horizontal = uri_or_data.flip_horizontal
37+
self.flip_vertical = uri_or_data.flip_vertical
38+
self.name = uri_or_data.name
39+
else:
40+
self.uri = self._normalize_uri(uri_or_data)
41+
self.rotation = rotation
42+
self.flip_horizontal = False
43+
self.flip_vertical = False
44+
self.name = "Unknown"
45+
46+
self.logger = logging.getLogger('mobileraker.webcam')
47+
48+
def _normalize_uri(self, uri: str) -> str:
49+
"""
50+
Normalize the URI by adding base_url if it's a relative path.
51+
52+
Args:
53+
uri (str): The URI to normalize.
54+
55+
Returns:
56+
str: The normalized URI.
57+
"""
58+
if not uri:
59+
return ""
60+
61+
# Check if the URI is already absolute
62+
if uri.startswith(('http://', 'https://')):
63+
return uri
64+
65+
# Handle relative paths
66+
if uri.startswith('/'):
67+
return f"{self.base_url}{uri}"
68+
else:
69+
return f"{self.base_url}/{uri}"
70+
71+
def capture_snapshot(self, max_width: int = 1024, quality: int = 85) -> Optional[bytes]:
72+
"""
73+
Captures and processes a snapshot from the webcam.
74+
75+
Args:
76+
max_width (int): Maximum width for the image, will scale proportionally. Default is 1024.
77+
quality (int): JPEG compression quality (1-100). Default is 85.
78+
79+
Returns:
80+
Optional[bytes]: The processed snapshot image as bytes if successful, or None on failure.
81+
"""
82+
self.logger.info("Capturing snapshot from webcam: %s at %s", self.name, self.uri)
83+
try:
84+
res = requests.get(self.uri, timeout=5)
85+
res.raise_for_status()
86+
87+
image = Image.open(BytesIO(res.content)).convert("RGB")
88+
89+
# Resize the image if width exceeds max_width
90+
if image.width > max_width:
91+
image = image.resize((max_width, int(image.height * (max_width / image.width))))
92+
93+
# Apply transformations
94+
if self.flip_horizontal:
95+
image = ImageOps.mirror(image)
96+
if self.flip_vertical:
97+
image = ImageOps.flip(image)
98+
if self.rotation:
99+
image = image.rotate(self.rotation)
100+
101+
# Convert to JPEG
102+
buffered = BytesIO()
103+
image.save(buffered, format="JPEG", optimize=True, quality=quality)
104+
105+
self.logger.info(
106+
"Snapshot captured successfully! Applied transformations: rotation=%i°, flip_h=%s, flip_v=%s",
107+
self.rotation, self.flip_horizontal, self.flip_vertical
108+
)
109+
return buffered.getvalue()
110+
111+
except requests.exceptions.ConnectionError:
112+
self.logger.error("Could not connect to webcam: %s", self.name)
113+
except requests.exceptions.Timeout:
114+
self.logger.error("Connection to webcam timed out: %s", self.name)
115+
except requests.exceptions.RequestException as e:
116+
self.logger.error("HTTP error while connecting to webcam: %s - %s", self.name, str(e))
117+
except Exception as e:
118+
self.logger.error("Error processing snapshot from %s: %s", self.name, str(e))
119+
120+
return None

0 commit comments

Comments
 (0)