mirror of
https://github.com/morpheus65535/bazarr.git
synced 2026-07-31 10:07:37 -04:00
397 lines
12 KiB
Python
397 lines
12 KiB
Python
# BSD 2-Clause License
|
|
#
|
|
# Apprise - Push Notification Library.
|
|
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# 1. Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
#
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
# Sources:
|
|
# - https://blink1.thingm.com/
|
|
# - https://github.com/todbot/blink1-python
|
|
# - https://github.com/todbot/blink1/blob/main/docs/blink1-hid-commands.md
|
|
|
|
import time
|
|
|
|
from ..common import NotifyType
|
|
from ..locale import gettext_lazy as _
|
|
from .base import NotifyBase
|
|
|
|
# Default our global support flag
|
|
NOTIFY_BLINK1_ENABLED = False
|
|
|
|
try:
|
|
import hid as blink1_hid
|
|
|
|
NOTIFY_BLINK1_ENABLED = True
|
|
|
|
except ImportError:
|
|
# No problem -- hidapi is an optional dependency. Users who wish to
|
|
# use this plugin must install it: pip install hidapi
|
|
pass
|
|
|
|
#
|
|
# blink(1) USB HID constants
|
|
#
|
|
|
|
# USB vendor / product identifiers for all blink(1) revisions
|
|
BLINK1_VENDOR_ID = 0x27B8
|
|
BLINK1_PRODUCT_ID = 0x01ED
|
|
|
|
# HID report size (report ID byte + 8 payload bytes)
|
|
BLINK1_REPORT_ID = 0x01
|
|
BLINK1_REPORT_SIZE = 9
|
|
|
|
# Command byte: 'c' == fade to RGB with a time argument
|
|
BLINK1_CMD_FADE = ord("c")
|
|
|
|
|
|
class Blink1LED:
|
|
"""
|
|
Defines the LED to focus on
|
|
"""
|
|
|
|
ALL = 0
|
|
FIRST = 1
|
|
SECOND = 2
|
|
|
|
|
|
BLINK1_LED_CHOICES = {
|
|
Blink1LED.ALL: "all",
|
|
Blink1LED.FIRST: "1",
|
|
Blink1LED.SECOND: "2",
|
|
}
|
|
|
|
# Maps inbound strings to Blink1LED constants; unknown values fall back to ALL
|
|
BLINK1_LED_MAP = {
|
|
"0": Blink1LED.ALL,
|
|
"all": Blink1LED.ALL,
|
|
"a": Blink1LED.ALL,
|
|
"1": Blink1LED.FIRST,
|
|
"2": Blink1LED.SECOND,
|
|
}
|
|
|
|
# How long (ms) to hold the LED colour after the fade completes
|
|
BLINK1_DEFAULT_DURATION_MS = 5000
|
|
BLINK1_MIN_DURATION_MS = 0
|
|
BLINK1_MAX_DURATION_MS = 300000 # 5 minutes
|
|
|
|
# Fade transition time (ms); 0 = instant
|
|
BLINK1_DEFAULT_FADE_MS = 0
|
|
BLINK1_MIN_FADE_MS = 0
|
|
BLINK1_MAX_FADE_MS = 10000 # 10 seconds
|
|
|
|
|
|
def _blink1_fade_buf(red, green, blue, fade_ms, ledn):
|
|
"""Build the 9-byte HID feature-report buffer for a fade-to-RGB command.
|
|
|
|
The blink(1) wire format (all values unsigned 8-bit unless noted):
|
|
[0] REPORT_ID (0x01)
|
|
[1] command 'c' (0x63)
|
|
[2] red
|
|
[3] green
|
|
[4] blue
|
|
[5] fade_time high byte (fade_time = fade_ms // 10, 16-bit big-endian)
|
|
[6] fade_time low byte
|
|
[7] ledn (0=all, 1=LED1, 2=LED2)
|
|
[8] 0x00 (padding)
|
|
"""
|
|
fade_time = int(fade_ms) // 10
|
|
th = (fade_time >> 8) & 0xFF
|
|
tl = fade_time & 0xFF
|
|
return [
|
|
BLINK1_REPORT_ID,
|
|
BLINK1_CMD_FADE,
|
|
int(red),
|
|
int(green),
|
|
int(blue),
|
|
th,
|
|
tl,
|
|
int(ledn),
|
|
0x00,
|
|
]
|
|
|
|
|
|
class NotifyBlink1(NotifyBase):
|
|
"""A wrapper for blink(1) USB LED notifications.
|
|
|
|
Colors are driven by Apprise's notification-type color map
|
|
(info=blue, success=green, warning=yellow, failure=red).
|
|
No external blink1 library is required; the USB HID wire protocol
|
|
is implemented directly via the hidapi package.
|
|
"""
|
|
|
|
# Set our global enabled flag
|
|
enabled = NOTIFY_BLINK1_ENABLED
|
|
|
|
requirements = {
|
|
"packages_required": "hidapi",
|
|
}
|
|
|
|
# The default descriptive name associated with the notification
|
|
service_name = _("blink(1)")
|
|
|
|
# The services URL
|
|
service_url = "https://blink1.thingm.com/"
|
|
|
|
# The default protocol
|
|
protocol = "blink1"
|
|
|
|
# A URL that takes you to the setup/help of the specific protocol
|
|
setup_url = "https://appriseit.com/services/blink1/"
|
|
|
|
# blink(1) is a local USB device; a title field has no meaning here.
|
|
title_maxlen = 0
|
|
|
|
# No throttling needed for a local USB device
|
|
request_rate_per_sec = 0
|
|
|
|
# URL templates
|
|
templates = (
|
|
"{schema}://",
|
|
"{schema}://{serial}/",
|
|
)
|
|
|
|
template_tokens = dict(
|
|
NotifyBase.template_tokens,
|
|
**{
|
|
"serial": {
|
|
"name": _("Serial Number"),
|
|
"type": "string",
|
|
},
|
|
},
|
|
)
|
|
|
|
template_args = dict(
|
|
NotifyBase.template_args,
|
|
**{
|
|
"duration": {
|
|
"name": _("Duration (ms)"),
|
|
"type": "int",
|
|
"min": BLINK1_MIN_DURATION_MS,
|
|
"max": BLINK1_MAX_DURATION_MS,
|
|
"default": BLINK1_DEFAULT_DURATION_MS,
|
|
},
|
|
"fade": {
|
|
"name": _("Fade Time (ms)"),
|
|
"type": "int",
|
|
"min": BLINK1_MIN_FADE_MS,
|
|
"max": BLINK1_MAX_FADE_MS,
|
|
"default": BLINK1_DEFAULT_FADE_MS,
|
|
},
|
|
"ledn": {
|
|
"name": _("LED Number"),
|
|
"type": "choice:int",
|
|
"values": BLINK1_LED_CHOICES,
|
|
"default": Blink1LED.ALL,
|
|
},
|
|
},
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
serial=None,
|
|
duration=None,
|
|
fade=None,
|
|
ledn=None,
|
|
**kwargs,
|
|
):
|
|
"""Initialize Blink1 Object."""
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
# Device serial number; None means "first available device".
|
|
# An underscore is accepted as a URL placeholder meaning "any".
|
|
serial = serial.strip() if isinstance(serial, str) else None
|
|
self.serial = serial if serial and serial != "_" else None
|
|
|
|
# Duration (ms) to hold the LED colour before turning it off
|
|
try:
|
|
self.duration = int(
|
|
BLINK1_DEFAULT_DURATION_MS if duration is None else duration
|
|
)
|
|
if not (
|
|
BLINK1_MIN_DURATION_MS
|
|
<= self.duration
|
|
<= BLINK1_MAX_DURATION_MS
|
|
):
|
|
raise ValueError("out of range")
|
|
|
|
except (TypeError, ValueError):
|
|
msg = (
|
|
f"blink(1) duration ({duration}) must be between"
|
|
f" {BLINK1_MIN_DURATION_MS} and"
|
|
f" {BLINK1_MAX_DURATION_MS} ms."
|
|
)
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg) from None
|
|
|
|
# Fade transition time (ms)
|
|
try:
|
|
self.fade = int(BLINK1_DEFAULT_FADE_MS if fade is None else fade)
|
|
if not (BLINK1_MIN_FADE_MS <= self.fade <= BLINK1_MAX_FADE_MS):
|
|
raise ValueError("out of range")
|
|
|
|
except (TypeError, ValueError):
|
|
msg = (
|
|
f"blink(1) fade ({fade}) must be between"
|
|
f" {BLINK1_MIN_FADE_MS} and {BLINK1_MAX_FADE_MS} ms."
|
|
)
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg) from None
|
|
|
|
# LED selector; unrecognised values silently fall back to ALL
|
|
self.ledn = (
|
|
BLINK1_LED_MAP.get(str(ledn).lower(), Blink1LED.ALL)
|
|
if ledn is not None
|
|
else Blink1LED.ALL
|
|
)
|
|
|
|
def _open_device(self):
|
|
"""Open and return a hidapi device handle for the blink(1).
|
|
|
|
Returns None and logs a warning when the device cannot be found.
|
|
"""
|
|
try:
|
|
dev = blink1_hid.device()
|
|
dev.open(
|
|
BLINK1_VENDOR_ID,
|
|
BLINK1_PRODUCT_ID,
|
|
self.serial,
|
|
)
|
|
return dev
|
|
|
|
except OSError:
|
|
msg = "Failed to open blink(1) device"
|
|
if self.serial:
|
|
msg += f" (serial={self.serial})"
|
|
msg += "."
|
|
self.logger.warning(msg)
|
|
return None
|
|
|
|
def _send_fade(self, dev, red, green, blue, fade_ms):
|
|
"""Send a single fade-to-RGB HID feature report.
|
|
|
|
Returns True on success, False if the report could not be sent.
|
|
"""
|
|
buf = _blink1_fade_buf(red, green, blue, fade_ms, self.ledn)
|
|
try:
|
|
rc = dev.send_feature_report(buf)
|
|
|
|
except OSError as e:
|
|
self.logger.warning("blink(1) HID write failed: %s", e)
|
|
return False
|
|
|
|
if rc != BLINK1_REPORT_SIZE:
|
|
self.logger.warning(
|
|
"blink(1) HID write returned %d (expected %d).",
|
|
rc,
|
|
BLINK1_REPORT_SIZE,
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
@property
|
|
def url_identifier(self):
|
|
"""Returns all fields that uniquely identify this connection."""
|
|
return (
|
|
self.protocol,
|
|
self.serial,
|
|
self.ledn,
|
|
)
|
|
|
|
def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
|
|
"""Perform blink(1) Notification."""
|
|
|
|
# Resolve the RGB triple for this notification type
|
|
r, g, b = self.color(notify_type=notify_type, color_type=tuple)
|
|
|
|
dev = self._open_device()
|
|
if dev is None:
|
|
return False
|
|
|
|
try:
|
|
# Always throttle before any device I/O
|
|
self.throttle()
|
|
|
|
if not self._send_fade(dev, r, g, b, self.fade):
|
|
return False
|
|
|
|
# Hold for fade + duration, then switch off
|
|
time.sleep((self.fade + self.duration) / 1000.0)
|
|
|
|
# Turn off: instant fade to black
|
|
if not self._send_fade(dev, 0, 0, 0, 0):
|
|
return False
|
|
|
|
finally:
|
|
dev.close()
|
|
|
|
self.logger.info("Sent blink(1) notification.")
|
|
return True
|
|
|
|
def url(self, privacy=False, *args, **kwargs):
|
|
"""Returns the URL built dynamically based on specified arguments."""
|
|
|
|
params = {
|
|
"duration": str(self.duration),
|
|
"fade": str(self.fade),
|
|
"ledn": str(self.ledn),
|
|
}
|
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
|
|
|
serial = self.serial if self.serial else "_"
|
|
return f"{self.protocol}://{serial}/?{NotifyBlink1.urlencode(params)}"
|
|
|
|
@staticmethod
|
|
def parse_url(url):
|
|
"""Parses the URL and returns enough arguments to re-instantiate."""
|
|
|
|
results = NotifyBase.parse_url(url, verify_host=False)
|
|
if not results:
|
|
return results
|
|
|
|
# The hostname, when present, is the device serial number.
|
|
# An underscore or empty value means "first available device".
|
|
host = results.get("host", "")
|
|
if host and host != "_":
|
|
results["serial"] = NotifyBlink1.unquote(host)
|
|
|
|
if results["qsd"].get("duration"):
|
|
results["duration"] = NotifyBlink1.unquote(
|
|
results["qsd"]["duration"]
|
|
)
|
|
|
|
if results["qsd"].get("fade"):
|
|
results["fade"] = NotifyBlink1.unquote(results["qsd"]["fade"])
|
|
|
|
if results["qsd"].get("ledn"):
|
|
results["ledn"] = NotifyBlink1.unquote(results["qsd"]["ledn"])
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def runtime_deps():
|
|
"""Return optional runtime dependency package names."""
|
|
return ("hid",)
|