├── .gitignore ├── .mypy.ini ├── .pylintrc ├── .sonarcloud.properties ├── Dockerfile ├── LICENSE ├── README.md ├── chrome2mqtt ├── __init__.py ├── __main__.py ├── alias.py ├── chrome2mqtt.py ├── chromeevent.py ├── chromestate.py ├── command.py ├── devicecoordinator.py ├── mqtt.py └── roomstate.py ├── logsetup.json-example ├── requirements.txt └── requirements ├── common.txt ├── dev.txt └── prod.txt /.gitignore: -------------------------------------------------------------------------------- 1 | xmltv/* 2 | __pycache__/* 3 | *.pyc 4 | .vscode/* 5 | env/* 6 | bin 7 | lib64 8 | share 9 | lib 10 | pyvenv.cfg 11 | logsetup.json 12 | *.log 13 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_return_any = True 3 | warn_unused_configs = True 4 | 5 | [mypy-pychromecast.*] 6 | ignore_missing_imports = True 7 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [Master] 2 | init-hook='import sys; sys.path.append("lib/python3.10/site-packages")' 3 | 4 | [MESSAGES CONTROL] 5 | disable=unsubscriptable-object, not-an-iterable, unsupported-membership-test -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.4-slim 2 | WORKDIR /usr/src/app 3 | COPY requirements.txt ./ 4 | COPY requirements ./requirements 5 | RUN pip install --no-cache-dir -r requirements.txt 6 | 7 | COPY . . 8 | COPY chrome2mqtt/ chrome2mqtt/ 9 | ENTRYPOINT [ "python", "-m", "chrome2mqtt" ] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Thomas Mørch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Chrome2MQTT 2 | ================== 3 | 4 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=tbowmo_chrome2mqtt&metric=alert_status)](https://sonarcloud.io/dashboard?id=tbowmo_chrome2mqtt) 5 | 6 | Python program to enable MQTT control endpoints for chromecasts (both audio and video). 7 | 8 | It listens to events from the connected chromecasts, and send their status to MQTT on the following events: 9 | * Change in which app is running 10 | * Media events (play, pause, stop etc.) 11 | * Media information (title, artist, album etc) 12 | 13 | It also listens to a MQTT topic, for commands. So you can send commands to your chromecasts like play, pause, stop etc. 14 | 15 | **NOTE!** Chromecast devices are collected into rooms, old operation where devices are treated seperately is available with option ```--standalone``` 16 | 17 | Table of contents 18 | === 19 | 20 | * [Chrome2MQTT](#chrome2mqtt) 21 | * [Table of contents](#table-of-contents) 22 | * [Installation](#installation) 23 | * [Starting python script with virtual-environment](#starting-python-script-with-virtual-environment) 24 | * [Starting with systemd](#starting-with-systemd) 25 | * [Start in a docker container](#start-in-a-docker-container) 26 | * [Command line options](#command-line-options) 27 | * [MQTT topics](#mqtt-topics) 28 | * [Rooms or single devices](#rooms-or-single-devices) 29 | * [Topics reported on by chromecast2mqtt](#topics-reported-on-by-chromecast2mqtt) 30 | * [Aliasing device topics](#aliasing-device-topics) 31 | * [JSON types](#json-types) 32 | * [Controlling your chromecast via mqtt](#controlling-your-chromecast-via-mqtt) 33 | * [Logging](#logging) 34 | * [Thanks to](#thanks-to) 35 | 36 | 37 | 38 | 39 | 40 | Installation 41 | === 42 | 43 | Starting python script with virtual-environment 44 | ----------------------------------------------- 45 | 46 | First ensure that you have at least python3.6 and venv installed, then create a new virtual environment for your python script: 47 | 48 | ```shell 49 | $ git clone https://github.com/tbowmo/chrome2mqtt.git 50 | $ cd chrome2mqtt 51 | $ python3 -m venv . 52 | $ source ./bin/activate 53 | $ pip install --no-cache-dir -r requirements.txt 54 | ``` 55 | 56 | You are now ready to start the script with 57 | 58 | `python -m chrome2mqtt ` 59 | 60 | Starting with systemd 61 | --- 62 | Start by following the description for enabling a virtual environment for python 63 | 64 | Then create a file named .service in /etc/systemd/system, with the following content (update paths and hosts as desired) 65 | ``` 66 | [Unit] 67 | Description=Chrome2mqtt 68 | Wants=network.target 69 | After=network.target 70 | 71 | [Service] 72 | Type=simple 73 | WorkingDirectory=/home/pi/chrome2mqtt 74 | ExecStart=/home/pi/chrome2mqtt/bin/python -m chrome2mqtt 75 | 76 | [Install] 77 | WantedBy=multi-user.target 78 | ``` 79 | 80 | Then in a terminal, execute the following two commands to enable your new service 81 | ```shell 82 | # systemctl enable chrome2mqtt.service 83 | # systemctl start chrome2mqtt.service 84 | ``` 85 | 86 | Start in a docker container 87 | --- 88 | If you wish to run inside a docker container, you can build your own image with `docker build . --tag chrome2mqtt` and then run it with `docker run --network=host chrome2mqtt ` 89 | 90 | Command line options 91 | ------------- 92 | Configure through command line options, as shown below 93 | ``` 94 | usage: chrome2mqtt [-h] [--mqttport MQTTPORT] [--mqttclient MQTTCLIENT] [--mqttroot MQTTROOT] [--mqttuser MQTTUSER] [--mqttpass MQTTPASS] [-H MQTTHOST] [-l LOGFILE] [-d] [-v] [-V] [-C] [-S] [--alias ALIAS] 95 | 96 | chrome2mqtt 97 | 98 | Connects your chromecasts to a mqtt-broker 99 | 100 | optional arguments: 101 | -h, --help show this help message and exit 102 | --mqttport MQTTPORT MQTT port on host 103 | --mqttclient MQTTCLIENT 104 | Client name for mqtt 105 | --mqttroot MQTTROOT MQTT root topic 106 | --mqttuser MQTTUSER MQTT user (if authentication is enabled for the broker) 107 | --mqttpass MQTTPASS MQTT password (if authentication is enabled for the broker) 108 | -H MQTTHOST, --mqtthost MQTTHOST 109 | MQTT Host 110 | -l LOGFILE, --logfile LOGFILE 111 | Log to filename 112 | -d, --debug loglevel debug 113 | -v, --verbose loglevel info 114 | -V, --version show program's version number and exit 115 | -C, --cleanup Cleanup mqtt topic on exit 116 | -S, --standalone Split into separate devices 117 | --alias ALIAS topic aliases for devices 118 | 119 | See more on https://github.com/tbowmo/chrome2mqtt/README.md 120 | ``` 121 | 122 | MQTT topics 123 | =========== 124 | 125 | Rooms or single devices 126 | ----------------------- 127 | chrome2mqtt can organize devices into rooms, or as standalone devices. Normal operation is to organize into rooms, where it can collect two devices (one audio and one video) into one endpoint, automatically directing commands to the active device. 128 | 129 | If you send a play command with an audio stream to a room, then it will automatically send this to the audio chromecast, and if you send a video stream it will be sent to the video chromecast. When starting the a new stream on an inactive chromecast, the other device will automatically be stopped, if it is playing. 130 | 131 | In rooms mode, it will use the device name of your chromecast to identify which room it is placed in. To do this you must have the follow the following scheme for naming the devices `\_tv` or `\_audio`. As an example, my chromecasts are named `livingroom_tv`and `livingroom_audio`, then I get a topic called `/livingroom/...` 132 | 133 | In standalone mode each device is treated as a separate one, and will have separate topics in your mqtt tree, the topic name will be a a normalized version of the friendly name given to each chromecast, where the name is converted to lower cases, and spaces have been replaced with underscores. 134 | 135 | Topics reported on by chromecast2mqtt 136 | ------------------------------------- 137 | Each room (or device if using `--standalone` will have a set of mqtt topics where status will be emitted 138 | The following topics will be used: 139 | 140 | | Topic | Payload | 141 | | ----- | ------- | 142 | | /\/app | Name of the currently running app (netflix, spotify, hbo, tidal etc). | 143 | | /\/state | Current state of the chromecast (playing, paused, buffering) | 144 | | /\/volume | Current volume level (an integer value between 0 and 100) | 145 | | /\/media | Returns a json object containing detailed information about the stream that is playing. Depending on the information from the app vendor. | 146 | | /\/capabilities | Json object containing the capabilities of the current activated app | 147 | 148 | *Notes:* 149 | *MQTT_ROOT is specified through option `--mqttroot` and will default to empty if not specified.* 150 | *ROOM will be device, if `--standalone` is specified* 151 | 152 | Aliasing device topics 153 | ---------------------- 154 | By supplying the `--alias` option and a list of device / alias pairs, you can rename your device topics. Specify a comma separated list of device / topic alias pairs: 155 | `device1=alias/path1,device2=alias/path2` if an alias is not found for a device upon discovery, then the device name itself will be used in topic generation. 156 | 157 | *NOTE* When running with `--standalone` you need to specify the full devicename, in lowercases and spaces converted into "_", that is if you have a device named "Livingroom audio" then you need to specify "livingroom\_audio" as device name. If running in standard mode (where chromecasts are collected into rooms), you need to use the room name for the device in aliasing. 158 | 159 | All aliases will have mqtt_root topic prepended (specified through option `--mqttroot`) 160 | 161 | JSON types 162 | ========== 163 | json formats for media and capabilities are as follows: 164 | 165 | media object: 166 | ```javascript 167 | { 168 | "title": string, 169 | "artist": string, 170 | "album": string, 171 | "album_art": string, // URL to a static image for the current playing track 172 | "metadata_type": number, 173 | "duration": number, // Total duration of the current media track 174 | "current_time": number, // current time index for the playing media 175 | "last_update": number, // timestamp for last media update 176 | } 177 | ``` 178 | 179 | capabilities object, this is a json containing state and feature capabilities of the current stream playing: 180 | ```javascript 181 | { 182 | "state": string, // same as sent to //state 183 | "volume": integer, // same as sent to //volume 184 | "muted": boolean, // indicates if device is muted 185 | "app": string, // same as sent to //app 186 | "app_icon": string, // url to a icon / image representing the current running app (Netflix, spotify etc. logo) 187 | "supported_features": { 188 | "skip_fwd": boolean, // indicates if skip_fwd is available 189 | "skip_bck": boolean, // indicates if skip_bck is available 190 | "pause": boolean, // indicates if pause is available 191 | "volume": boolean, // indicates if volume control is available 192 | "mute": boolean, // indicates if audio stream can be muted 193 | } 194 | } 195 | ``` 196 | 197 | Controlling your chromecast via mqtt 198 | ==================================== 199 | It's possible to control the chromecasts, by sending a message to the `/\/control/` endpoint for each device, where `` is one of the following list, some takes a payload as well: 200 | 201 | | Action | Payload required | Value for payload | 202 | | ------ | ------- | ----------------- | 203 | | play | Optional | If no payload, just starts from a pause condition, otherwise send a json object {"link":string, "type": string} | 204 | | pause | Optional | If no payload is supplied it will toggle pause state, otherwise send 1/True to pause or 0/False to play | 205 | | stop | No | | 206 | | next | No | | 207 | | prev | No | | 208 | | mute | Optional| If no payload is supplied it will toggle mute state, otherwise send 1/True to mute or 0/False to unmute | 209 | | volume | Required|Integer 0 - 100 specifying volume level | 210 | | update | No | Requests the chromecast to send a media status update 211 | | quit | No | Quit the currently running app on the chromecast 212 | 213 | Play 214 | ---- 215 | The json object for the play command contains a link to the media file you want to play, and a mime type for the content: 216 | 217 | ```javascript 218 | // Start playing a mp3 audio file 219 | { "link": "https://link.to/awesome.mp3", "type": "audio/mp3" } 220 | 221 | // Start playing a mp4 video file 222 | { "link": "https://link.to/awesome.mp4", "type": "video/mp4" } 223 | 224 | // Special mimetype "youtube" 225 | { "link": "zSmOvYzSeaQ", "type": "youtube" } 226 | ``` 227 | 228 | Logging 229 | ======= 230 | Logging can be configured in two ways, either simple with commandline options, -v for verbose (loglevel INFO and higher) or -d for debug (loglevel DEBUG or higher). You can also specify a file to dump your logs to with -l / --logfile. 231 | 232 | A more advanced setup is to make a json file in your root project with the name logsetup.json, an example file (logsetup.json-example) is included, that you can rename and use as a basis for your own setup. This file will take precedence over the commandline arguments, which will be ignored if the file is found. 233 | 234 | Thanks to 235 | ========= 236 | I would like to thank [Paulus Schoutsen](https://github.com/balloob) for his excelent work on [pychromecast](https://github.com/balloob/pychromecast), without his library this project couldn't have been made. 237 | -------------------------------------------------------------------------------- /chrome2mqtt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbowmo/chrome2mqtt/116bb4092c7c8732f5ccabf2c73302bbd69f3cab/chrome2mqtt/__init__.py -------------------------------------------------------------------------------- /chrome2mqtt/__main__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Chrome2Mqtt mqtt handler for chromecast devices. 3 | ''' 4 | from .chrome2mqtt import main_loop 5 | 6 | if __name__ == '__main__': 7 | main_loop() 8 | -------------------------------------------------------------------------------- /chrome2mqtt/alias.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Simple alias handling, for rooms 3 | ''' 4 | 5 | class AliasException(Exception): 6 | ''' 7 | Exception class for alias errors 8 | ''' 9 | 10 | class Alias: 11 | #pylint: disable=too-few-public-methods 12 | ''' 13 | Handles aliases for room / devices in the mqtt tree. 14 | aliases is setup with an comma delimited string with alias pairs: 15 | device1=alias/path1,device2=alias/path2,.... 16 | ''' 17 | __aliases: dict = {} 18 | def __init__(self, alias_string=None): 19 | print('alias init') 20 | try: 21 | if alias_string is not None: 22 | alias_pairs = alias_string.split(',') 23 | for alias_pair in alias_pairs: 24 | alias = alias_pair.split('=') 25 | self.__aliases.update({alias[0]: alias[1]}) 26 | except IndexError as error: 27 | raise AliasException('You have an error in your alias definition') from error 28 | print(self.__aliases) 29 | 30 | def get(self, device_name): 31 | ''' 32 | return an aliased deviceName, if an alias is found, 33 | otherwise it returns the deviceName as is. 34 | ''' 35 | if device_name not in self.__aliases: 36 | return device_name 37 | return self.__aliases[device_name] 38 | -------------------------------------------------------------------------------- /chrome2mqtt/chrome2mqtt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | ''' 3 | Program that handles chromecast integration to mqtt. 4 | 5 | Copyright 2018: Thomas Bowman Morch 6 | ''' 7 | from os import path 8 | import sys 9 | import logging.config 10 | import logging 11 | import socket 12 | import atexit 13 | import signal 14 | import json 15 | import argparse 16 | 17 | from .mqtt import MQTT 18 | from .devicecoordinator import DeviceCoordinator 19 | from .alias import Alias 20 | 21 | __version__ = __VERSION__ = "1.0.0" 22 | 23 | def parse_args(argv=None): 24 | #pylint: disable=line-too-long 25 | ''' Command line argument parser ''' 26 | parser = argparse.ArgumentParser(prog='chrome2mqtt', 27 | formatter_class=argparse.RawDescriptionHelpFormatter, 28 | description='chrome2mqtt\n\nConnects your chromecasts to a mqtt-broker', 29 | epilog='See more on https://github.com/tbowmo/chrome2mqtt/README.md' 30 | ) 31 | parser.add_argument('--mqttport', action="store", default=1883, type=int, help="MQTT port on host") 32 | parser.add_argument('--mqttclient', action="store", default=socket.gethostname(), help="Client name for mqtt") 33 | parser.add_argument('--mqttroot', action="store", default="chromecast", help="MQTT root topic") 34 | parser.add_argument('--mqttuser', action="store", default=None, help="MQTT user (if authentication is enabled for the broker)") 35 | parser.add_argument('--mqttpass', action="store", default=None, help="MQTT password (if authentication is enabled for the broker)") 36 | parser.add_argument('-H', '--mqtthost', action="store", default="127.0.0.1", help="MQTT Host") 37 | parser.add_argument('-l', '--logfile', action="store", default=None, help="Log to filename") 38 | parser.add_argument('-d', '--debug', action="store_const", dest="log", const=logging.DEBUG, help="loglevel debug") 39 | parser.add_argument('-v', '--verbose', action="store_const", dest="log", const=logging.INFO, help="loglevel info") 40 | parser.add_argument('-V', '--version', action='version', version=f'%(prog)s {__VERSION__}') 41 | parser.add_argument('-C', '--cleanup', action="store_true", dest="cleanup", help="Cleanup mqtt topic on exit") 42 | parser.add_argument('-S', '--standalone', action="store_true", dest="split", help="Split into separate devices") 43 | parser.add_argument('--alias', action="store", help="topic aliases for devices") 44 | return parser.parse_args(argv) 45 | 46 | def start_banner(args): 47 | ''' Print banner message for the programme ''' 48 | print('Chromecast2MQTT') 49 | print('Connecting to mqtt host ' + args.mqtthost + ' port ' + str(args.mqttport)) 50 | print('Using mqtt root ' + args.mqttroot) 51 | 52 | def setup_logging( 53 | file=None, 54 | level=logging.WARNING 55 | ): 56 | ''' Initialize logging ''' 57 | if path.isfile('./logsetup.json'): 58 | with open(file='./logsetup.json', mode='rt', encoding='utf-8') as options_file: 59 | config = json.load(options_file) 60 | logging.config.dictConfig(config) 61 | elif file is not None: 62 | logging.basicConfig(level=level, 63 | filename=file, 64 | format='%(asctime)s %(name)-16s %(levelname)-8s %(message)s') 65 | else: 66 | logging.basicConfig(level=level, 67 | format='%(asctime)s %(name)-16s %(levelname)-8s %(message)s') 68 | 69 | def main_loop(): 70 | '''Main operating loop, discovers chromecasts, and run forever until ctrl-c is received''' 71 | 72 | assert sys.version_info >= (3, 11), "You need at least python 3.11 to run this program" 73 | 74 | args = parse_args() 75 | start_banner(args) 76 | 77 | setup_logging(args.logfile, args.log) 78 | 79 | try: 80 | mqtt = MQTT( 81 | host=args.mqtthost, 82 | port=args.mqttport, 83 | client=args.mqttclient, 84 | root=args.mqttroot, 85 | user=args.mqttuser, 86 | password=args.mqttpass 87 | ) 88 | except ConnectionError as exception: 89 | print(f'Error connecting to mqtt host {args.mqtthost} on port {args.mqttport}') 90 | print(exception) 91 | sys.exit(1) 92 | 93 | alias = Alias(args.alias) 94 | coordinator = DeviceCoordinator(mqtt, alias, args.split) 95 | 96 | def last_will(): 97 | '''Send a last will to the mqtt server''' 98 | if args.cleanup: 99 | coordinator.cleanup() 100 | 101 | atexit.register(last_will) 102 | 103 | def signal_handler(sig, frame): #pylint: disable=unused-argument 104 | print('Shutting down') 105 | sys.exit(0) 106 | 107 | signal.signal(signal.SIGINT, signal_handler) 108 | while True: 109 | coordinator.discover() 110 | signal.pause() 111 | -------------------------------------------------------------------------------- /chrome2mqtt/chromeevent.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Handles events from a chromecast device, and reports these to various endpoints 3 | ''' 4 | from time import sleep 5 | from logging import getLogger, Logger 6 | from typing import Callable 7 | from attrs import define, field 8 | from pychromecast import Chromecast 9 | from .command import Command, CommandException 10 | from .chromestate import ChromeState 11 | 12 | @define 13 | class ChromeEvent: 14 | ''' 15 | Handle events to and from registered chromecast devices. 16 | 17 | Internally it listens for new media and / or cast messages from 18 | the chromecast it handles. and calls the callback specified in order 19 | to update status. 20 | 21 | Also handles actions destined for the specific device, by calling 22 | the action method 23 | ''' 24 | #pylint: disable=no-member 25 | device: Chromecast = field() 26 | status: ChromeState = field() 27 | callback: Callable[[ChromeState, str], None] = field(default = None) 28 | name: str = field(default=None) 29 | __log: Logger = field(init=False, default = None) 30 | __command: Command = field(init=False, default = None) 31 | 32 | def __attrs_post_init__(self): 33 | self.__log = getLogger(f'chromevent_{self.device.cast_type}_{self.name}') 34 | 35 | self.device.register_status_listener(self) 36 | self.device.media_controller.register_status_listener(self) 37 | 38 | self.device.wait() 39 | self.__command = Command(self.device, self.status) 40 | 41 | def action(self, command, parameter): 42 | ''' Handle action to the chromecast device ''' 43 | try: 44 | result = self.__command.execute(command, parameter) 45 | if not result: 46 | self.__log.error( 47 | 'Command "%s" not supported with parameter "%s"', 48 | command, 49 | parameter 50 | ) 51 | if result: 52 | self.__log.info('Success') 53 | except CommandException as exception: 54 | self.__log.warning(exception) 55 | except Exception as exception: #pylint: disable=broad-except 56 | self.__log.error(command) 57 | self.__log.error(parameter) 58 | self.__log.error(exception) 59 | 60 | def new_cast_status(self, status): 61 | ''' Receives updates when new app is starting on the chromecast ''' 62 | self.__log.info(status) 63 | self.status.set_cast_state(status) 64 | self.__callback(self.status) 65 | 66 | def new_media_status(self, status): 67 | ''' Receives updates when new media changes is happening ''' 68 | self.__log.info(status) 69 | self.status.set_media_state(status) 70 | self.__callback(self.status) 71 | if self.status.state == 'PLAYING' and self.status.app == 'Netflix': 72 | # Netflix is not reporting nicely on play / pause state changes, 73 | # so we poll it to get an up to date status 74 | sleep(1) 75 | self.device.media_controller.update_status() 76 | 77 | def __callback(self, msg: ChromeState): 78 | if self.callback is not None: 79 | self.callback(msg, self.name) #pylint: disable=not-callable 80 | -------------------------------------------------------------------------------- /chrome2mqtt/chromestate.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Stores state of a chromedevice in a more MQTT friendly maner. 3 | 4 | use ChromeState from this module, to handle the chromecast states. 5 | ''' 6 | import json 7 | import abc 8 | from time import time 9 | from attrs import define, field, asdict 10 | from pychromecast.controllers.receiver import CastStatus 11 | from pychromecast.controllers.media import MediaStatus 12 | 13 | class BaseHelper(metaclass=abc.ABCMeta): 14 | ''' Base class, defining methods for jsonifying data ''' 15 | #pylint: disable=missing-docstring 16 | def json(self): 17 | return json.dumps(asdict(self)) 18 | 19 | @abc.abstractmethod 20 | def set_cast_state(self, status: CastStatus): 21 | pass 22 | 23 | @abc.abstractmethod 24 | def set_media_state(self, media_status: MediaStatus): 25 | pass 26 | 27 | @define 28 | class Media(BaseHelper): 29 | ''' 30 | Helper class for holding information about the current playing media 31 | ''' 32 | #pylint: disable=too-many-instance-attributes, missing-docstring 33 | #All attributes are needed in this object, to hold media info. 34 | 35 | device: str = field() 36 | title: str = field(init = False, default='') 37 | artist: str = field(init = False, default='') 38 | album: str = field(init = False, default='') 39 | album_art: str = field(init = False, default='') 40 | metadata_type: str = field(init = False, default=None) 41 | content_id: str = field(init = False, default=None) 42 | duration: float = field(init = False, default=None) 43 | current_time: float = field(init = False, default=None) 44 | last_update: float = field(init = False, default=None) 45 | 46 | def set_media_state(self, media_status: MediaStatus): 47 | self.title = media_status.title 48 | self.artist = media_status.artist 49 | self.album = media_status.album_name 50 | self.metadata_type = media_status.metadata_type 51 | self.duration = media_status.duration 52 | self.current_time = media_status.current_time 53 | self.last_update = time() 54 | if media_status.images: 55 | images = media_status.images 56 | self.album_art = images[0].url 57 | else: 58 | self.album_art = '' 59 | self.content_id = media_status.content_id 60 | 61 | def set_cast_state(self, status: CastStatus): 62 | # Empty as we do not use any info on the CastStatus object 63 | pass 64 | 65 | @define 66 | class SupportedFeatures(BaseHelper): 67 | ''' 68 | Helper class for holding information about supported features of the current stream / app 69 | ''' 70 | skip_fwd: bool = field(init = False, default=False) 71 | skip_bck: bool = field(init = False, default=False) 72 | pause: bool = field(init = False, default=False) 73 | volume: bool = field(init = False, default=False) 74 | mute: bool = field(init = False, default=False) 75 | 76 | def set_media_state(self, media_status: MediaStatus): 77 | self.skip_fwd = media_status.supports_queue_next or media_status.supports_skip_forward 78 | self.skip_bck = media_status.supports_queue_prev or media_status.supports_skip_backward 79 | self.pause = media_status.supports_pause 80 | self.volume = media_status.supports_stream_volume 81 | self.mute = media_status.supports_stream_mute 82 | 83 | def set_cast_state(self, status: CastStatus): 84 | # Empty as we do not use any info on the CastStatus object 85 | pass 86 | 87 | @define 88 | class State(BaseHelper): 89 | ''' 90 | Helper class holding information about current state of the chromecast 91 | ''' 92 | 93 | device: str = field() 94 | app: str = field(init = False, default='None') 95 | state: str = field(init = False, default='STOPPED') 96 | volume: int = field(init = False, default=0) 97 | muted: bool = field(init = False, default=False) 98 | app_icon: str = field(init = False, default='') 99 | supported_features: SupportedFeatures = field(init = False, default=SupportedFeatures()) 100 | 101 | def set_cast_state(self, status: CastStatus): 102 | self.app = status.display_name or 'None' 103 | if self.app == 'Backdrop': 104 | self.app = 'None' 105 | self.volume = round(status.volume_level * 100) 106 | self.muted = status.volume_muted == 1 107 | self.app_icon = status.icon_url 108 | self.supported_features.set_cast_state(status) #pylint: disable=no-member 109 | 110 | def set_media_state(self, media_status: MediaStatus): 111 | self.state = media_status.player_state 112 | self.supported_features.set_media_state(media_status) #pylint: disable=no-member 113 | 114 | class ChromeState: 115 | ''' 116 | Holds state of the chromecast media_status 117 | ''' 118 | __state = State('') 119 | __media = Media('') 120 | 121 | def __init__(self, name): 122 | self.__name = name 123 | self.clear() 124 | 125 | @property 126 | def name(self): 127 | ''' name of the device ''' 128 | return self.__name 129 | 130 | @property 131 | def app(self): 132 | ''' which app is currently running ''' 133 | return self.__state.app 134 | 135 | @property 136 | def state(self): 137 | ''' what is the current state (playing, stopped, idle, ect) ''' 138 | return self.__state.state 139 | 140 | @property 141 | def volume(self): 142 | ''' Volume of the device ''' 143 | if self.__state.muted: 144 | return 0 145 | return self.__state.volume 146 | 147 | @property 148 | def muted(self): 149 | ''' are we muted? ''' 150 | return self.__state.muted 151 | 152 | @property 153 | def media_json(self): 154 | ''' JSON representing the media currently playing ''' 155 | return self.__media.json() 156 | 157 | @property 158 | def state_json(self): 159 | ''' JSON representing the current state object (playing, devicename, volume etc.)''' 160 | return self.__state.json() 161 | 162 | def clear(self): 163 | ''' Clear all fields ''' 164 | self.__state = State(self.__name) 165 | self.__media = Media(self.__name) 166 | 167 | def set_cast_state(self, status: CastStatus): 168 | ''' Update status object ''' 169 | app_name = status.display_name 170 | if app_name is None or app_name == 'Backdrop' or app_name == '': 171 | self.clear() 172 | else: 173 | self.__media.set_cast_state(status) 174 | self.__state.set_cast_state(status) 175 | 176 | def set_media_state(self, media_status: MediaStatus): 177 | ''' Update media object ''' 178 | self.__state.set_media_state(media_status) 179 | self.__media.set_media_state(media_status) 180 | -------------------------------------------------------------------------------- /chrome2mqtt/command.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Handles command dispatching for chomecast devices, use the Command class 3 | ''' 4 | from inspect import signature 5 | from types import SimpleNamespace as Namespace 6 | import json 7 | from time import sleep 8 | from attrs import field, define 9 | from pychromecast import Chromecast 10 | from pychromecast.controllers.youtube import YouTubeController 11 | from .chromestate import ChromeState 12 | 13 | class CommandException(Exception): 14 | ''' 15 | Exception class for command errors 16 | ''' 17 | 18 | @define 19 | class Command: 20 | ''' 21 | Class that handles dispatching of commands to a chromecast device 22 | ''' 23 | #pylint: disable=no-member 24 | device: Chromecast = field() 25 | status: ChromeState = field() 26 | youtube: YouTubeController = field(init=False, default= YouTubeController()) 27 | 28 | def __attrs_post_init__(self): 29 | self.device.register_handler(self.youtube) 30 | 31 | def execute(self, cmd, payload): 32 | '''execute command on the chromecast 33 | 34 | Arguments: 35 | cmd {[string]} 36 | payload {[string]} 37 | 38 | Returns: 39 | Result -- result object from the command execution 40 | ''' 41 | method = getattr(self, cmd, lambda x: False) 42 | sig = signature(method) 43 | if str(sig) == '(x)': 44 | return False 45 | 46 | if len(sig.parameters) == 0: #pylint: disable=len-as-condition 47 | method() 48 | else: 49 | method(payload) 50 | return True 51 | 52 | def stop(self): 53 | ''' Stop playing on the chromecast ''' 54 | self.device.media_controller.stop() 55 | 56 | def pause(self, pause): 57 | ''' Pause playback ''' 58 | if (pause is None or pause == ''): 59 | if self.device.media_controller.status.player_is_paused: 60 | self.device.media_controller.play() 61 | else: 62 | self.device.media_controller.pause() 63 | else: 64 | pause = str(pause).lower() 65 | if pause in ('1', 'true'): 66 | self.device.media_controller.pause() 67 | elif pause in ('0', 'false'): 68 | self.device.media_controller.play() 69 | else: 70 | raise CommandException(f'Pause could not match "{pause}" as a parameter') 71 | 72 | def next(self): 73 | ''' Skip to next track ''' 74 | self.device.media_controller.queue_next() 75 | 76 | def prev(self): 77 | ''' Rewind to previous track ''' 78 | self.device.media_controller.queue_prev() 79 | 80 | def quit(self): 81 | ''' Quit running application on chromecast ''' 82 | self.status.clear() 83 | self.device.quit_app() 84 | 85 | def poweroff(self): 86 | ''' Poweroff, same as quit ''' 87 | self.quit() 88 | 89 | def play(self, media=None): 90 | ''' Play a media URL on the chromecast ''' 91 | if media is None or media == '': 92 | self.device.media_controller.play() 93 | else: 94 | self.__play_content(media) 95 | 96 | def __play_content(self, media): 97 | media_obj = "Failed" 98 | 99 | try: 100 | media_obj = json.loads(media, object_hook=lambda d: Namespace(**d)) 101 | except Exception as error: 102 | raise CommandException(f"{media} is not a valid json object") from error 103 | 104 | if not hasattr(media_obj, 'link') or not hasattr(media_obj, 'type'): 105 | raise CommandException( 106 | f'Wrong parameter, it should be json object with: {{link: string, type: string}}, you sent {media}' #pylint: disable=line-too-long 107 | ) 108 | 109 | retry = 3 110 | media_type = media_obj.type.lower() 111 | while True: 112 | if media_type == 'youtube': 113 | self.youtube.play_video(media_obj.link) 114 | else: 115 | self.device.media_controller.play_media(media_obj.link, media_obj.type) 116 | sleep(0.5) 117 | if self.device.media_controller.status.player_is_playing or retry == 0: 118 | break 119 | retry = retry - 1 120 | if retry == 0 and not self.device.media_controller.status.player_is_playing: 121 | raise CommandException('Could not start chromecast') 122 | 123 | def volume(self, level): 124 | ''' Set the volume level ''' 125 | if level is None or level == '': 126 | raise CommandException('You need to specify volume level') 127 | if int(level) > 100: 128 | level = 100 129 | if int(level) < 0: 130 | level = 0 131 | self.device.set_volume(int(level) / 100.0) 132 | 133 | def mute(self, mute): 134 | ''' Mute device ''' 135 | if (mute is None or mute == ''): 136 | self.device.set_volume_muted(not self.device.status.volume_muted) 137 | else: 138 | mute = str(mute).lower() 139 | if mute in ('1', 'true'): 140 | self.device.set_volume_muted(True) 141 | elif mute in ('0', 'false'): 142 | self.device.set_volume_muted(False) 143 | else: 144 | raise CommandException(f'Mute could not match "{mute}" as a parameter') 145 | 146 | def update(self): 147 | ''' Request an update from the chromecast ''' 148 | self.device.media_controller.update_status() 149 | -------------------------------------------------------------------------------- /chrome2mqtt/devicecoordinator.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Handler for chromecast devices, is able to collect devices into rooms, so multiple chromecast 3 | devices can be controlled as one mqtt topic / endpoint. 4 | ''' 5 | import re 6 | from typing import Dict 7 | import pychromecast 8 | from attrs import define, field 9 | from .chromeevent import ChromeEvent 10 | from .chromestate import ChromeState 11 | from .mqtt import MQTT 12 | from .roomstate import RoomState 13 | from .alias import Alias 14 | 15 | @define 16 | class DeviceCoordinator: 17 | ''' 18 | Handles chromecast devices, organizing them into rooms (normal behavior), 19 | or as standalone devices (device_split=true) 20 | ''' 21 | #pylint: disable=no-member 22 | 23 | mqtt: MQTT = field() 24 | alias: Alias = field() 25 | device_split = field(default=False) 26 | __rooms: Dict[str, RoomState] = field(init= False, default={}) 27 | __device_split_char = field(init=False, default='_') 28 | 29 | def discover(self): 30 | ''' 31 | Start discovering chromecasts on the network. 32 | ''' 33 | pychromecast.get_chromecasts(callback=self.__search_callback, blocking=False) 34 | 35 | def cleanup(self): 36 | ''' Clean up MQTT topics for all registered rooms ''' 37 | for room in self.__rooms: 38 | self.__cleanup(room) 39 | 40 | def __mqtt_action(self, client, userdata, message): #pylint: disable=unused-argument 41 | parameter = message.payload.decode("utf-8") 42 | command = self.__decode_mqtt_command(message) 43 | room = self.__rooms[self.__decode_mqtt_room(message)] 44 | room.action(command, parameter) 45 | 46 | def __decode_mqtt_command(self, message): 47 | '''Get the command that was sent in the topic''' 48 | regex = r"\/control\/(.+)" 49 | matches = re.search(regex, message.topic) 50 | assert matches is not None, f'Can not extract command from topic "{message.topic}"' 51 | return matches.group(1) 52 | 53 | def __decode_mqtt_room(self, message): 54 | '''Get the room name from our own topics''' 55 | regex = rf"{self.mqtt.root}(.+)\/control\/.*" 56 | matches = re.search(regex, message.topic) 57 | assert matches is not None, f'Can not extract room name from topic "{message.topic}"' #pylint: disable=line-too-long 58 | return matches.group(1) 59 | 60 | def __room(self, device): 61 | room = device 62 | if not self.device_split: 63 | room = device.split(self.__device_split_char)[0] 64 | return self.alias.get(room) 65 | 66 | def __device(self, device): 67 | if self.device_split: 68 | return device 69 | return device.split(self.__device_split_char)[1] 70 | 71 | def __event_handler(self, state: ChromeState, device=None): 72 | room_name = self.__room(device) 73 | self.__rooms[room_name].state = state 74 | 75 | self.__mqtt_publish(self.__rooms[room_name]) 76 | 77 | def __search_callback(self, chromecast: pychromecast.Chromecast): 78 | name = chromecast.name.lower().replace(' ', self.__device_split_char) 79 | room_name = self.__room(name) 80 | device = self.__device(name) 81 | if room_name not in self.__rooms: 82 | self.__rooms.update({room_name : RoomState(room_name, self.device_split)}) 83 | control_path = f'{room_name}/control/+' 84 | self.mqtt.message_callback_add(control_path, self.__mqtt_action) 85 | 86 | room = self.__rooms[room_name] 87 | room.add_device(ChromeEvent(chromecast, 88 | ChromeState(device), 89 | self.__event_handler, 90 | name), 91 | device) 92 | 93 | def __mqtt_publish(self, room: RoomState, force=False): 94 | base = room.room 95 | self.mqtt.publish(f'{base}/device', room.active_device, retain=True) 96 | if (force or room.media_changed): 97 | self.mqtt.publish(f'{base}/media', room.media_json, retain=True) 98 | if (force or room.state_changed): 99 | self.mqtt.publish(f'{base}/capabilities', room.state_json, retain=True) 100 | self.mqtt.publish(f'{base}/state', room.state.state, retain=True) 101 | self.mqtt.publish(f'{base}/volume', room.state.volume, retain=True) 102 | self.mqtt.publish(f'{base}/app', room.state.app, retain=True) 103 | 104 | def __cleanup(self, room: str): 105 | self.mqtt.publish(f'{room}/capabilities', None, retain=False) 106 | self.mqtt.publish(f'{room}/media', None, retain=False) 107 | self.mqtt.publish(f'{room}/state', None, retain=False) 108 | self.mqtt.publish(f'{room}/volume', None, retain=False) 109 | self.mqtt.publish(f'{room}/app', None, retain=False) 110 | self.mqtt.publish(f'{room}/device', None, retain=False) 111 | -------------------------------------------------------------------------------- /chrome2mqtt/mqtt.py: -------------------------------------------------------------------------------- 1 | ''' Internal MQTT handler for the project ''' 2 | from logging import getLogger 3 | from time import sleep 4 | from paho.mqtt.client import Client, CallbackAPIVersion 5 | 6 | class MQTT(Client): 7 | ''' Mqtt handler, takes care of adding a root topic to all topics 8 | managed by this class, so others do not have to be aware of 9 | this root topic 10 | ''' 11 | #pylint: disable=too-many-instance-attributes 12 | __is_connected = False 13 | root = '' 14 | 15 | def __init__(self, host='127.0.0.1', port=1883, client='chrome', root='', user=None, password=None): #pylint: disable=too-many-arguments, line-too-long 16 | super().__init__(CallbackAPIVersion.VERSION2, client) 17 | self.subscriptions = [] 18 | self.host = host 19 | self.port = int(port) 20 | if root != '': 21 | if root[-1] != '/': 22 | self.root = root + '/' 23 | else: 24 | self.root = root 25 | self.on_log = self.__on_log 26 | self.on_connect = self.__on_connect 27 | self.on_disconnect = self.__on_disconnect 28 | self.log = getLogger('mqtt') 29 | if user is not None: 30 | self.username_pw_set(user, password) 31 | self.__connect() 32 | 33 | def subscribe(self, topic, qos=0): #pylint: disable=arguments-differ 34 | ''' subscribe to a topic ''' 35 | if topic not in self.subscriptions: 36 | self.subscriptions.append(topic) 37 | topic = self.root + topic 38 | self.log.info('subscribing - %s : %s', topic, len(self.subscriptions)) 39 | super().subscribe(topic, qos) 40 | 41 | def message_callback_add(self, sub, callback): 42 | ''' Add message callbacks, is called when a message matching topic is received ''' 43 | self.subscribe(sub) 44 | sub = self.root + sub 45 | super().message_callback_add(sub, callback) 46 | 47 | def publish(self, topic, payload=None, qos=0, retain=False): #pylint: disable=arguments-differ 48 | ''' publish on mqtt, adding root topic to the topic ''' 49 | topic = self.root + topic 50 | if self.__is_connected: 51 | super().publish(topic, payload, qos, retain) 52 | 53 | def __on_connect(self, client, userdata, flags, reason_code, properties): # pylint: disable=unused-argument, invalid-name, arguments-differ, too-many-arguments 54 | ''' handle connection established ''' 55 | self.log.warning('Connect %s', reason_code) 56 | if reason_code == 0: 57 | self.__is_connected = True 58 | for subscription in self.subscriptions: 59 | self.subscribe(subscription) 60 | else: 61 | raise ConnectionError('Connection failed') 62 | 63 | def __on_disconnect(self, client, userdata, flags, reason_code, properties): # pylint: disable=unused-argument, invalid-name, arguments-differ, too-many-arguments 64 | ''' handle disconnects ''' 65 | self.log.warning('Disconnected, reconnecting') 66 | self.__is_connected = False 67 | self.reconnect() 68 | 69 | def __on_log(self, mqttc, obj, level, buf): # pylint: disable=unused-argument, arguments-differ 70 | ''' Log handler function ''' 71 | self.log.debug(buf) 72 | 73 | def __connect(self): 74 | ''' Connect to the mqtt broker ''' 75 | self.connect(self.host, self.port, 30) 76 | self.loop_start() 77 | while not self.__is_connected: 78 | sleep(1) 79 | self.log.info('Waiting for connection to %s', self.host) 80 | -------------------------------------------------------------------------------- /chrome2mqtt/roomstate.py: -------------------------------------------------------------------------------- 1 | ''' RoomState is handling the state of a single room with multiple chromecasts 2 | ''' 3 | import json 4 | from logging import getLogger, Logger 5 | from types import SimpleNamespace as Namespace 6 | from time import sleep 7 | from attrs import define, field 8 | from .chromestate import ChromeState 9 | 10 | class StateChanged: 11 | '''Keeps track if state has changed''' 12 | __last_state = None 13 | __changed = True 14 | 15 | @property 16 | def changed(self): 17 | ''' Returns true if state has changed since last time we called this method ''' 18 | state = self.__changed 19 | self.__changed = False 20 | return state 21 | 22 | def update(self, state): 23 | '''Updates internal state, and check if it is changed from last update''' 24 | if self.__last_state != state: 25 | self.__last_state = state 26 | self.__changed = True 27 | 28 | @define 29 | class RoomState: 30 | ''' Handles state of a room, with multiple chromecast devices. ''' 31 | #pylint: disable=no-member, too-many-instance-attributes 32 | room: str = field() 33 | device_split: bool = field(default = False) 34 | __state: ChromeState = field(init = False, default=None) 35 | __active:str = field(init=False, default='N/A') 36 | __state_media: StateChanged = field(init=False, default=StateChanged()) 37 | __state_state: StateChanged = field(init=False, default=StateChanged()) 38 | __devices: dict = field(init=False, default={}) 39 | __log: Logger = field(init=False) 40 | 41 | def __attrs_post_init__(self): 42 | self.__log = getLogger(f'roomState_{self.room}') 43 | 44 | @property 45 | def active_device(self): 46 | ''' Name of the device that is active ''' 47 | return self.__active 48 | 49 | @property 50 | def state_json(self): 51 | ''' JSON representation of the state object from the currently active device''' 52 | return self.__state.state_json 53 | 54 | @property 55 | def media_json(self): 56 | ''' JSON representation of the media of the currently active device in the room ''' 57 | return self.__state.media_json 58 | 59 | @property 60 | def state_changed(self): 61 | ''' Returns true if state has changed since last time this was called ''' 62 | return self.__state_state.changed 63 | 64 | @property 65 | def media_changed(self): 66 | ''' Returns true if media has changed since last time ''' 67 | return self.__state_media.changed 68 | 69 | @property 70 | def state(self): 71 | ''' state of the active device ''' 72 | return self.__state 73 | 74 | @state.setter 75 | def state(self, new_state: ChromeState): 76 | ''' Update room state from chromecast devices ''' 77 | ignored_apps = new_state.app in ('Default Media Receiver', 'None') 78 | if self.__state is not None \ 79 | and self.__state.app != 'None' \ 80 | and new_state.name != self.__active \ 81 | and ignored_apps: 82 | return 83 | 84 | if self.__active != new_state.name \ 85 | and self.__active != 'N/A' \ 86 | and self.__state.app != 'None': 87 | self.__log.info('quit %s', self.__active) 88 | self.__devices[self.__active].action('quit', '') 89 | 90 | self.__state = new_state 91 | self.__active = new_state.name 92 | self.__state_media.update(new_state.media_json) 93 | self.__state_state.update(new_state.state_json) 94 | 95 | def add_device(self, chrome_device, name): 96 | ''' Add device to this room ''' 97 | self.__devices.update({name: chrome_device}) 98 | 99 | def action(self, command, parameter, all_devices=False): 100 | ''' Room level action, sends the action to the active chromecast ''' 101 | if command == 'play': 102 | try: 103 | device = self.__active 104 | if not self.device_split: 105 | device = self.__determine_playable_device(parameter) 106 | self.__devices[device].action('play', parameter) 107 | except ValueError: 108 | self.__devices[self.__active].action(command, parameter) 109 | else: 110 | if all_devices: 111 | for dev in self.__devices.items(): 112 | dev.action(command, parameter) 113 | else: 114 | self.__devices[self.__active].action(command, parameter) 115 | 116 | def __determine_playable_device(self, parameter): 117 | media = json.loads(parameter, object_hook=lambda d: Namespace(**d)) 118 | device = 'tv' 119 | if hasattr(media, 'type') and media.type.lower().startswith('audio'): 120 | device = 'audio' 121 | 122 | if device not in self.__devices: 123 | # Some naming mishaps for the devices detected, so we are not able to 124 | # determine which device to play on, so start on the first available one 125 | self.__log.error('Device %s is not available, defaulting to first in list', device) 126 | device = list(self.__devices.keys())[0] 127 | 128 | if device != self.__active: 129 | self.__devices[self.__active].action('quit', '') 130 | sleep(0.5) 131 | return device 132 | -------------------------------------------------------------------------------- /logsetup.json-example: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "disable_existing_loggers": false, 4 | "formatters": { 5 | "simple": { 6 | "format": "%(asctime)s - %(levelname)-8s - %(name)s - %(message)s" 7 | }, 8 | "nolevel": { 9 | "format": "%(asctime)s - %(name)s - %(message)s" 10 | } 11 | }, 12 | 13 | "handlers": { 14 | "console": { 15 | "class": "logging.StreamHandler", 16 | "level": "DEBUG", 17 | "formatter": "simple", 18 | "stream": "ext://sys.stdout" 19 | }, 20 | 21 | "info_file_handler": { 22 | "class": "logging.handlers.RotatingFileHandler", 23 | "level": "INFO", 24 | "formatter": "nolevel", 25 | "filename": "info.log", 26 | "maxBytes": 10485760, 27 | "backupCount": 20, 28 | "encoding": "utf8" 29 | }, 30 | 31 | "error_file_handler": { 32 | "class": "logging.handlers.RotatingFileHandler", 33 | "level": "ERROR", 34 | "formatter": "nolevel", 35 | "filename": "errors.log", 36 | "maxBytes": 10485760, 37 | "backupCount": 20, 38 | "encoding": "utf8" 39 | } 40 | }, 41 | 42 | "loggers": { 43 | "my_module": { 44 | "level": "ERROR", 45 | "handlers": ["console"], 46 | "propagate": false 47 | } 48 | }, 49 | 50 | "root": { 51 | "level": "INFO", 52 | "handlers": ["console", "info_file_handler", "error_file_handler"] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/prod.txt -------------------------------------------------------------------------------- /requirements/common.txt: -------------------------------------------------------------------------------- 1 | pychromecast==14.0.0 2 | pytz>=2024.01 3 | paho-mqtt>=2.0.0 4 | attrs 5 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r common.txt 2 | mypy 3 | pylint 4 | -------------------------------------------------------------------------------- /requirements/prod.txt: -------------------------------------------------------------------------------- 1 | -r common.txt 2 | --------------------------------------------------------------------------------