Add display brightness and power control

- New display service with DDC/CI brightness and power control via screen_brightness_control and monitorcontrol
- New /api/display/* endpoints (monitors, brightness, power)
- Display tab in WebUI with per-monitor brightness sliders and power toggle
- EDID resolution parsing to distinguish same-name monitors
- Throttled brightness slider (50ms) matching volume control pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 13:54:43 +03:00
parent 03a1b30cd8
commit a568608ec3
10 changed files with 617 additions and 3 deletions

View File

@@ -16,7 +16,7 @@ from fastapi.staticfiles import StaticFiles
from . import __version__ from . import __version__
from .auth import get_token_label, token_label_var from .auth import get_token_label, token_label_var
from .config import settings, generate_default_config, get_config_dir from .config import settings, generate_default_config, get_config_dir
from .routes import audio_router, browser_router, callbacks_router, health_router, media_router, scripts_router from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, media_router, scripts_router
from .services import get_media_controller from .services import get_media_controller
from .services.websocket_manager import ws_manager from .services.websocket_manager import ws_manager
@@ -116,6 +116,7 @@ def create_app() -> FastAPI:
app.include_router(audio_router) app.include_router(audio_router)
app.include_router(browser_router) app.include_router(browser_router)
app.include_router(callbacks_router) app.include_router(callbacks_router)
app.include_router(display_router)
app.include_router(health_router) app.include_router(health_router)
app.include_router(media_router) app.include_router(media_router)
app.include_router(scripts_router) app.include_router(scripts_router)

View File

@@ -3,8 +3,9 @@
from .audio import router as audio_router from .audio import router as audio_router
from .browser import router as browser_router from .browser import router as browser_router
from .callbacks import router as callbacks_router from .callbacks import router as callbacks_router
from .display import router as display_router
from .health import router as health_router from .health import router as health_router
from .media import router as media_router from .media import router as media_router
from .scripts import router as scripts_router from .scripts import router as scripts_router
__all__ = ["audio_router", "browser_router", "callbacks_router", "health_router", "media_router", "scripts_router"] __all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "media_router", "scripts_router"]

View File

@@ -0,0 +1,59 @@
"""Display brightness and power control API endpoints."""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..services.display_service import (
get_brightness,
list_monitors,
set_brightness,
set_power,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/display", tags=["display"])
class BrightnessRequest(BaseModel):
brightness: int = Field(ge=0, le=100)
class PowerRequest(BaseModel):
on: bool
@router.get("/monitors")
async def get_monitors(
refresh: bool = False, _: str = Depends(verify_token)
) -> list[dict]:
"""List all connected monitors with brightness and power info."""
monitors = list_monitors(force_refresh=refresh)
logger.debug("Found %d monitors", len(monitors))
return [m.to_dict() for m in monitors]
@router.post("/brightness/{monitor_id}")
async def set_monitor_brightness(
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
) -> dict:
"""Set brightness for a specific monitor."""
success = set_brightness(monitor_id, request.brightness)
if success:
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
return {"success": success}
@router.post("/power/{monitor_id}")
async def set_monitor_power(
monitor_id: int, request: PowerRequest, _: str = Depends(verify_token)
) -> dict:
"""Turn a monitor on or off."""
action = "on" if request.on else "off"
success = set_power(monitor_id, request.on)
if success:
logger.info("Set monitor %d power %s", monitor_id, action)
return {"success": success}

View File

@@ -0,0 +1,208 @@
"""Display brightness and power control service."""
import logging
import platform
import struct
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
_sbc = None
_monitorcontrol = None
def _load_sbc():
global _sbc
if _sbc is None:
try:
import screen_brightness_control as sbc
_sbc = sbc
except ImportError:
logger.warning("screen_brightness_control not installed - brightness control unavailable")
return _sbc
def _load_monitorcontrol():
global _monitorcontrol
if _monitorcontrol is None:
try:
import monitorcontrol
_monitorcontrol = monitorcontrol
except ImportError:
logger.warning("monitorcontrol not installed - display power control unavailable")
return _monitorcontrol
def _parse_edid_resolution(edid_hex: str) -> str | None:
"""Parse resolution from EDID hex string (first detailed timing descriptor)."""
try:
edid = bytes.fromhex(edid_hex)
if len(edid) < 58:
return None
dtd = edid[54:]
pixel_clock = struct.unpack('<H', dtd[0:2])[0]
if pixel_clock == 0:
return None
h_active = dtd[2] | ((dtd[4] >> 4) << 8)
v_active = dtd[5] | ((dtd[7] >> 4) << 8)
return f"{h_active}x{v_active}"
except Exception:
return None
@dataclass
class MonitorInfo:
id: int
name: str
brightness: int | None
power_supported: bool
power_on: bool = True
model: str = ""
manufacturer: str = ""
resolution: str | None = None
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"brightness": self.brightness,
"power_supported": self.power_supported,
"power_on": self.power_on,
"model": self.model,
"manufacturer": self.manufacturer,
"resolution": self.resolution,
}
# Cache for monitor list
_monitor_cache: list[MonitorInfo] | None = None
_cache_time: float = 0
_CACHE_TTL = 5.0 # seconds
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
"""List all connected monitors with their current brightness."""
global _monitor_cache, _cache_time
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL:
return _monitor_cache
sbc = _load_sbc()
if sbc is None:
return []
monitors = []
try:
info_list = sbc.list_monitors_info()
brightnesses = sbc.get_brightness()
# Get DDC/CI monitors for power state
mc = _load_monitorcontrol()
ddc_monitors = []
if mc:
try:
ddc_monitors = mc.get_monitors()
except Exception:
pass
for i, info in enumerate(info_list):
name = info.get("name", f"Monitor {i}")
model = info.get("model", "")
manufacturer = info.get("manufacturer", "")
brightness = brightnesses[i] if i < len(brightnesses) else None
# VCP method monitors support DDC/CI power control
method = str(info.get("method", "")).lower()
power_supported = "vcp" in method
# Parse resolution from EDID
edid = info.get("edid", "")
resolution = _parse_edid_resolution(edid) if edid else None
# Read power state via DDC/CI
power_on = True
if power_supported and i < len(ddc_monitors):
try:
with ddc_monitors[i] as mon:
power_mode = mon.get_power_mode()
power_on = power_mode == mc.PowerMode.on
except Exception:
pass
monitors.append(MonitorInfo(
id=i,
name=name,
brightness=brightness,
power_supported=power_supported,
power_on=power_on,
model=model,
manufacturer=manufacturer,
resolution=resolution,
))
except Exception as e:
logger.error("Failed to enumerate monitors: %s", e)
_monitor_cache = monitors
_cache_time = time.time()
return monitors
def get_brightness(monitor_id: int) -> int | None:
"""Get brightness for a specific monitor."""
sbc = _load_sbc()
if sbc is None:
return None
try:
result = sbc.get_brightness(display=monitor_id)
if isinstance(result, list):
return result[0] if result else None
return result
except Exception as e:
logger.error("Failed to get brightness for monitor %d: %s", monitor_id, e)
return None
def set_brightness(monitor_id: int, value: int) -> bool:
"""Set brightness for a specific monitor (0-100)."""
sbc = _load_sbc()
if sbc is None:
return False
value = max(0, min(100, value))
try:
sbc.set_brightness(value, display=monitor_id)
# Invalidate cache
global _monitor_cache
_monitor_cache = None
return True
except Exception as e:
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
return False
def set_power(monitor_id: int, on: bool) -> bool:
"""Turn a monitor on or off via DDC/CI."""
mc = _load_monitorcontrol()
if mc is None:
logger.error("monitorcontrol not available for power control")
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
logger.error("Monitor %d not found in DDC/CI monitors", monitor_id)
return False
with ddc_monitors[monitor_id] as monitor:
if on:
monitor.set_power_mode(mc.PowerMode.on)
else:
monitor.set_power_mode(mc.PowerMode.off_soft)
# Invalidate cache
global _monitor_cache
_monitor_cache = None
return True
except Exception as e:
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
return False

View File

@@ -41,7 +41,8 @@
:root[data-theme="light"] .player-container, :root[data-theme="light"] .player-container,
:root[data-theme="light"] .browser-container, :root[data-theme="light"] .browser-container,
:root[data-theme="light"] .scripts-container, :root[data-theme="light"] .scripts-container,
:root[data-theme="light"] .script-management { :root[data-theme="light"] .script-management,
:root[data-theme="light"] .display-container {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
} }
@@ -886,6 +887,163 @@ button:disabled {
color: var(--text-primary); color: var(--text-primary);
} }
/* Display Control Section */
.display-container {
background: var(--bg-secondary);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.display-monitors {
display: flex;
flex-direction: column;
gap: 1rem;
}
.display-monitor-card {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
}
.display-monitor-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.display-monitor-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.display-monitor-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.display-monitor-name {
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.display-monitor-details {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.display-power-btn {
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
}
.display-power-btn.on {
color: var(--accent);
border-color: var(--accent);
}
.display-power-btn.on:hover {
color: var(--error);
border-color: var(--error);
}
.display-power-btn.off {
color: var(--error);
border-color: var(--error);
opacity: 0.6;
}
.display-power-btn.off:hover {
color: var(--accent);
border-color: var(--accent);
opacity: 1;
}
.display-brightness-control {
display: flex;
align-items: center;
gap: 0.75rem;
}
.display-brightness-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.display-brightness-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: var(--bg-primary);
outline: none;
cursor: pointer;
}
.display-brightness-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
transition: transform 0.15s;
}
.display-brightness-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
}
.display-brightness-slider:hover::-webkit-slider-thumb {
transform: scale(1.2);
}
.display-brightness-slider:hover::-moz-range-thumb {
transform: scale(1.2);
}
.display-brightness-slider:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.display-brightness-value {
font-size: 0.813rem;
color: var(--text-secondary);
min-width: 36px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.add-card { .add-card {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -101,6 +101,10 @@
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg> <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
<span data-i18n="tab.player">Player</span> <span data-i18n="tab.player">Player</span>
</button> </button>
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>
<span data-i18n="tab.display">Display</span>
</button>
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1"> <button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg> <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
<span data-i18n="tab.browser">Browser</span> <span data-i18n="tab.browser">Browser</span>
@@ -324,6 +328,16 @@
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
<!-- Display Control Section -->
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
<div class="display-monitors" id="displayMonitors">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.loading">Loading monitors...</p>
</div>
</div>
</div>
</div> </div>
<!-- Add/Edit Script Dialog --> <!-- Add/Edit Script Dialog -->

View File

@@ -121,6 +121,11 @@
updateTabIndicator(activeBtn); updateTabIndicator(activeBtn);
} }
// Load display monitors when switching to display tab
if (tabName === 'display') {
loadDisplayMonitors();
}
// Save to localStorage // Save to localStorage
localStorage.setItem('activeTab', tabName); localStorage.setItem('activeTab', tabName);
@@ -2769,3 +2774,157 @@ async function saveFolder(event) {
closeFolderDialog(); closeFolderDialog();
} }
// ============================================================
// Display Brightness & Power Control
// ============================================================
let displayBrightnessTimers = {};
const DISPLAY_THROTTLE_MS = 50;
async function loadDisplayMonitors() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('displayMonitors');
if (!container) return;
try {
const response = await fetch('/api/display/monitors?refresh=true', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.error">Failed to load monitors</p>
</div>`;
return;
}
const monitors = await response.json();
if (monitors.length === 0) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.no_monitors">No monitors detected</p>
</div>`;
return;
}
container.innerHTML = '';
monitors.forEach(monitor => {
const card = document.createElement('div');
card.className = 'display-monitor-card';
card.id = `monitor-card-${monitor.id}`;
const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0;
const brightnessDisabled = monitor.brightness === null ? 'disabled' : '';
let powerBtn = '';
if (monitor.power_supported) {
powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
</button>`;
}
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' · ');
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
card.innerHTML = `
<div class="display-monitor-header">
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg>
<div class="display-monitor-info">
<span class="display-monitor-name">${monitor.name}</span>
${detailsHtml}
</div>
${powerBtn}
</div>
<div class="display-brightness-control">
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
</svg>
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
</div>`;
container.appendChild(card);
});
} catch (e) {
console.error('Failed to load display monitors:', e);
}
}
function onDisplayBrightnessInput(monitorId, value) {
const label = document.getElementById(`brightness-val-${monitorId}`);
if (label) label.textContent = `${value}%`;
if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = setTimeout(() => {
sendDisplayBrightness(monitorId, parseInt(value));
displayBrightnessTimers[monitorId] = null;
}, DISPLAY_THROTTLE_MS);
}
function onDisplayBrightnessChange(monitorId, value) {
if (displayBrightnessTimers[monitorId]) {
clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = null;
}
sendDisplayBrightness(monitorId, parseInt(value));
}
async function sendDisplayBrightness(monitorId, brightness) {
const token = localStorage.getItem('media_server_token');
try {
await fetch(`/api/display/brightness/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ brightness })
});
} catch (e) {
console.error('Failed to set brightness:', e);
}
}
async function toggleDisplayPower(monitorId, monitorName) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/display/power/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ on: newState })
});
const data = await response.json();
if (data.success) {
if (btn) {
btn.classList.toggle('on', newState);
btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on');
}
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
} else {
showToast('Failed to change monitor power', 'error');
}
} catch (e) {
console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error');
}
}

View File

@@ -119,6 +119,12 @@
"tab.quick_actions": "Actions", "tab.quick_actions": "Actions",
"tab.scripts": "Scripts", "tab.scripts": "Scripts",
"tab.callbacks": "Callbacks", "tab.callbacks": "Callbacks",
"tab.display": "Display",
"display.loading": "Loading monitors...",
"display.error": "Failed to load monitors",
"display.no_monitors": "No monitors detected",
"display.power_on": "Turn on",
"display.power_off": "Turn off",
"browser.title": "Media Browser", "browser.title": "Media Browser",
"browser.home": "Home", "browser.home": "Home",
"browser.manage_folders": "Manage Folders", "browser.manage_folders": "Manage Folders",

View File

@@ -119,6 +119,12 @@
"tab.quick_actions": "Действия", "tab.quick_actions": "Действия",
"tab.scripts": "Скрипты", "tab.scripts": "Скрипты",
"tab.callbacks": "Колбэки", "tab.callbacks": "Колбэки",
"tab.display": "Дисплей",
"display.loading": "Загрузка мониторов...",
"display.error": "Не удалось загрузить мониторы",
"display.no_monitors": "Мониторы не обнаружены",
"display.power_on": "Включить",
"display.power_off": "Выключить",
"browser.title": "Медиа Браузер", "browser.title": "Медиа Браузер",
"browser.home": "Главная", "browser.home": "Главная",
"browser.manage_folders": "Управление папками", "browser.manage_folders": "Управление папками",

View File

@@ -40,6 +40,8 @@ windows = [
"pywin32>=306", "pywin32>=306",
"comtypes>=1.2.0", "comtypes>=1.2.0",
"pycaw>=20230407", "pycaw>=20230407",
"screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0",
] ]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",