Skip to content

Commit e421acd

Browse files
author
tom
committed
release getting mac to work first try
1 parent ee7656d commit e421acd

10 files changed

Lines changed: 398 additions & 35 deletions

File tree

README_macOS.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@ Detailed macOS-specific setup for **PyCron Video Alarm Manager**.
66

77
## 📦 Prerequisites
88

9-
1. **MPV or VLC Media Player**: Required for reliable media playback.
10-
- Install via Homebrew:
11-
```bash
12-
brew install mpv
13-
```
14-
or
15-
```bash
16-
brew install --cask vlc
17-
```
9+
1. **VLC Media Player** *(required for video/audio playback)*:
10+
```bash
11+
brew install --cask vlc
12+
```
1813

1914
2. **Python 3**:
20-
- Install via Homebrew: `brew install python`
15+
```bash
16+
brew install python
17+
```
18+
19+
3. **brightness CLI** *(optional — only needed if your sequences use a `set_brightness` action)*:
20+
```bash
21+
brew install brightness
22+
```
23+
Without it, brightness actions are silently skipped.
2124

2225
## 🚀 Setting Up the Application
2326

src/core/factory.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ def get_platform_managers() -> tuple[PowerManager, DisplayManager]:
2323
except ImportError as e:
2424
logging.error(f"Failed to import Linux managers: {e}")
2525
raise
26-
26+
27+
elif sys.platform == "darwin":
28+
try:
29+
from platforms.macos.power import MacOSPowerManager
30+
from platforms.macos.display import MacOSDisplayManager
31+
return MacOSPowerManager(), MacOSDisplayManager()
32+
except ImportError as e:
33+
logging.error(f"Failed to import macOS managers: {e}")
34+
raise
35+
2736
else:
2837
raise NotImplementedError(f"Platform {sys.platform} is not supported.")

src/logic/media_utils.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def play_audio_with_retry(file_path, duration=None, gain=0, system_volume=None,
5151

5252
def get_player_priority(file_path):
5353
"""Get the preferred player."""
54-
if sys.platform == "win32":
54+
if sys.platform in ("win32", "darwin"):
5555
return ["vlc"]
5656
return ["mpv"]
5757

@@ -65,7 +65,7 @@ def execute_media(file_path, config=None):
6565

6666
success = False
6767
try:
68-
if sys.platform == "win32":
68+
if sys.platform in ("win32", "darwin"):
6969
success = play_video_vlc(file_path, config)
7070
player_name = "vlc"
7171
else:
@@ -86,24 +86,29 @@ def execute_media(file_path, config=None):
8686
execute_video = execute_media
8787

8888
def get_vlc_path():
89-
"""Find the VLC executable natively on Windows."""
89+
"""Find the VLC executable on Windows or macOS."""
9090
vlc_path = shutil.which("vlc")
9191
if vlc_path:
9292
return vlc_path
93-
93+
9494
if sys.platform == "win32":
9595
program_files = os.environ.get("ProgramFiles", "C:\\Program Files")
9696
program_files_x86 = os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")
97-
97+
9898
fallbacks = [
9999
os.path.join(program_files, "VideoLAN", "VLC", "vlc.exe"),
100100
os.path.join(program_files_x86, "VideoLAN", "VLC", "vlc.exe")
101101
]
102-
102+
103103
for path in fallbacks:
104104
if path and os.path.isfile(path) and os.access(path, os.X_OK):
105105
return path
106-
106+
107+
elif sys.platform == "darwin":
108+
mac_vlc = "/Applications/VLC.app/Contents/MacOS/VLC"
109+
if os.path.isfile(mac_vlc) and os.access(mac_vlc, os.X_OK):
110+
return mac_vlc
111+
107112
return None
108113

109114
def get_mpv_path():
@@ -112,24 +117,24 @@ def get_mpv_path():
112117

113118
def check_media_player_installed():
114119
"""Verify strictly if the native media player is installed."""
115-
if sys.platform == "win32":
120+
if sys.platform in ("win32", "darwin"):
116121
return get_vlc_path() is not None
117122
return get_mpv_path() is not None
118123

119124
def detect_available_players():
120125
"""
121126
Detect which video players are installed on the system.
122-
Returns: ['vlc'] on Windows, ['mpv'] on Linux.
127+
Returns: ['vlc'] on Windows/macOS, ['mpv'] on Linux.
123128
"""
124129
available = []
125-
126-
if sys.platform == "win32":
130+
131+
if sys.platform in ("win32", "darwin"):
127132
if get_vlc_path():
128133
available.append("vlc")
129134
else:
130135
if get_mpv_path():
131136
available.append("mpv")
132-
137+
133138
return available
134139

135140
def play_video_vlc(file_path, config=None):

src/logic/scheduler.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ def __init__(self):
2121
self.platform_scheduler = WindowsScheduler()
2222
except ImportError as e:
2323
logging.error(f"Failed to load Windows Scheduler: {e}")
24+
elif sys.platform == "darwin":
25+
try:
26+
from platforms.macos.scheduler import MacOSScheduler
27+
self.platform_scheduler = MacOSScheduler()
28+
except ImportError as e:
29+
logging.error(f"Failed to load macOS Scheduler: {e}")
2430
else:
2531
logging.error(f"Unsupported platform for scheduling: {sys.platform}")
2632

src/main.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,13 @@ def main():
6262

6363
# Determine project root — different for frozen (PyInstaller) vs development
6464
if getattr(sys, 'frozen', False):
65-
# Frozen: executable lives in dist/ or alongside sequences/
66-
# Use the directory containing the executable, NOT sys._MEIPASS
67-
project_root = os.path.dirname(os.path.abspath(sys.executable))
65+
# Frozen: executable lives in dist/ (Linux/Win) or App.app/Contents/MacOS/ (macOS)
66+
exe_path = os.path.abspath(sys.executable)
67+
if sys.platform == 'darwin' and '.app/Contents/MacOS' in exe_path:
68+
# Walk up: MacOS -> Contents -> App.app -> parent folder (where video/, audio/ live)
69+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(exe_path)))
70+
else:
71+
project_root = os.path.dirname(exe_path)
6872
else:
6973
# Development: src/main.py → project root is one level up
7074
project_root = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
@@ -123,7 +127,7 @@ def main():
123127
logging.info(f"Attempting to delete one-time cron job for '{args.execute_sequence}'...")
124128
try:
125129
import os
126-
if sys.platform.startswith('linux'):
130+
if sys.platform.startswith('linux') or sys.platform == 'darwin':
127131
# Directly remove the cron job by matching our unique ID + marker
128132
try:
129133
from crontab import CronTab

src/platforms/macos/__init__.py

Whitespace-only changes.

src/platforms/macos/display.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import subprocess
2+
import logging
3+
from core.interfaces import DisplayManager
4+
5+
6+
class MacOSDisplayManager(DisplayManager):
7+
"""macOS display manager.
8+
9+
Brightness control requires the 'brightness' CLI tool:
10+
brew install brightness
11+
12+
If it is not installed, brightness calls log a warning and return False
13+
silently — no crash, no error dialog.
14+
15+
Display sleep / wake uses pmset which ships with macOS.
16+
"""
17+
18+
def _run(self, cmd):
19+
"""Run a command. Returns True on success."""
20+
try:
21+
subprocess.run(cmd, check=True, capture_output=True)
22+
return True
23+
except subprocess.CalledProcessError as e:
24+
logging.debug(f"Command failed: {' '.join(cmd)}{e}")
25+
return False
26+
except FileNotFoundError:
27+
logging.debug(f"Command not found: {cmd[0]}")
28+
return False
29+
30+
# ------------------------------------------------------------------
31+
# Display power
32+
# ------------------------------------------------------------------
33+
34+
def turn_off(self) -> bool:
35+
"""Put the display to sleep immediately."""
36+
return self._run(["pmset", "displaysleepnow"])
37+
38+
def turn_on(self) -> bool:
39+
"""Wake the display (brief caffeinate wakeup assertion)."""
40+
return self._run(["caffeinate", "-u", "-t", "1"])
41+
42+
# ------------------------------------------------------------------
43+
# Brightness
44+
# ------------------------------------------------------------------
45+
46+
def set_brightness(self, level: int) -> bool:
47+
"""Set screen brightness (0-100) via the 'brightness' Homebrew CLI.
48+
49+
If 'brightness' is not installed the call returns False and logs a
50+
warning — it does NOT raise an exception or show an error dialog.
51+
52+
Install with: brew install brightness
53+
"""
54+
level = max(0, min(100, int(level)))
55+
brightness_val = f"{level / 100.0:.2f}"
56+
57+
if self._run(["brightness", brightness_val]):
58+
logging.info(f"Brightness set to {level}% via 'brightness' CLI")
59+
return True
60+
61+
logging.warning(
62+
"macOS brightness control unavailable. "
63+
"Install with: brew install brightness"
64+
)
65+
return False
66+
67+
def get_brightness(self) -> int:
68+
"""Get current brightness (0-100) via the 'brightness' CLI."""
69+
try:
70+
result = subprocess.run(
71+
["brightness", "-l"],
72+
capture_output=True,
73+
text=True
74+
)
75+
# Output contains lines like: "display 0: brightness 0.7500"
76+
for line in result.stdout.splitlines():
77+
if "brightness" in line:
78+
parts = line.strip().split()
79+
val = float(parts[-1])
80+
return int(val * 100)
81+
except Exception:
82+
pass
83+
return 100 # Safe default

src/platforms/macos/power.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import subprocess
2+
import logging
3+
from core.interfaces import PowerManager
4+
5+
6+
class MacOSPowerManager(PowerManager):
7+
"""macOS power manager using caffeinate to inhibit sleep.
8+
9+
caffeinate ships with macOS — no installation required.
10+
11+
Flags used:
12+
-d Prevent display from sleeping
13+
-i Prevent system idle sleep
14+
-m Prevent disk from sleeping
15+
-s Prevent system sleep (AC power)
16+
"""
17+
18+
def __init__(self):
19+
self._caffeinate_proc = None
20+
21+
def inhibit_sleep(self, reason: str = "Video Alarm Active") -> bool:
22+
"""Start caffeinate as a background process to block sleep."""
23+
if self._caffeinate_proc and self._caffeinate_proc.poll() is None:
24+
logging.info("Sleep already inhibited via caffeinate")
25+
return True
26+
try:
27+
self._caffeinate_proc = subprocess.Popen(
28+
["caffeinate", "-d", "-i", "-m", "-s"],
29+
stdout=subprocess.DEVNULL,
30+
stderr=subprocess.DEVNULL
31+
)
32+
logging.info(f"Sleep inhibited via caffeinate (pid {self._caffeinate_proc.pid})")
33+
return True
34+
except Exception as e:
35+
logging.error(f"Failed to start caffeinate: {e}")
36+
return False
37+
38+
def uninhibit_sleep(self) -> bool:
39+
"""Terminate caffeinate to allow sleep again."""
40+
if self._caffeinate_proc:
41+
try:
42+
self._caffeinate_proc.terminate()
43+
self._caffeinate_proc.wait(timeout=5)
44+
logging.info("Released sleep inhibit (caffeinate terminated)")
45+
except Exception as e:
46+
logging.error(f"Failed to terminate caffeinate: {e}")
47+
try:
48+
self._caffeinate_proc.kill()
49+
except Exception:
50+
pass
51+
return False
52+
finally:
53+
self._caffeinate_proc = None
54+
return True

0 commit comments

Comments
 (0)