diff --git a/docs/about.md b/docs/about.md index 4bd57410..bbc204ed 100644 --- a/docs/about.md +++ b/docs/about.md @@ -3,6 +3,7 @@ This app is used to control a cocktail machine and easily prepare cocktails over a nice-looking user interface. It also offers the option to create and manage your recipes and ingredients over the interface and calculates the possible cocktails to prepare over given ingredients. Track and display cocktail data for different teams to even further increase the fun. +Have also a look into the [User Guide](assets/CocktailBerryUserGuide.pdf) Let's get started! ## tl;dr diff --git a/docs/assets/CocktailBerryUserGuide.pdf b/docs/assets/CocktailBerryUserGuide.pdf new file mode 100644 index 00000000..034a7eac Binary files /dev/null and b/docs/assets/CocktailBerryUserGuide.pdf differ diff --git a/docs/faq.md b/docs/faq.md index a2febfca..4f4fd065 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -144,4 +144,15 @@ It's for some optional advanced features you can add anytime you are interested If your pumps got a long tube to the bottle, the first cocktail may have too little volume. You can set the `MAKER_TUBE_VOLUME` to an approximate value which corresponds to the average of the tube volume. -When applying a new bottle, CocktailBerry will also pump that much volume up. \ No newline at end of file +When applying a new bottle, CocktailBerry will also pump that much volume up. + +### Whats up with LEDs + +You can define one or more pins which control a LED (array). +The LEDs will light up during cocktail preparation, as well when the cocktail is finished. +If it's an controllable WS28x LED you can activate the setting. +Instead of just turning on / off / blinking, the LED will then have some advanced light effects. +If you want to have multiple ring LEDs having the effect synchronously, you can define the number of identical daisy chained rings. +The program will then not treat this chain as one, but as multiple chains. +This does not include some default LEDs used for general lighting of the machine, because they usually don't need controlling. +It's better to directly connect them to the main source current and turn them on when the machine is turned on. \ No newline at end of file diff --git a/docs/features.md b/docs/features.md index f9b5151a..f4959381 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,6 +20,7 @@ Usually, updating to the lastest version is always a good idea. | **v1.11.0** | Internet check and time adjustment feature | | **v1.13.1** | Clearing the database over the CLI | | **v1.14.0** | Can invert pin, set simultaneous pump count, generic board | +| **v1.15.0** | Control a LED during cocktail preparation | !!! abstract "And much More" This list is by no means a full list of changes. diff --git a/docs/index.md b/docs/index.md index 3a00f925..0d58b7af 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ CocktailBerry can do: - Keep track of cocktail count and volume from different teams for some fun competition - Select different themes to fit your liking - Switch between user interface languages +- Support WS281x LEDs on your machine In addition, there is the possibility to use and set up a second device as a dashboard: diff --git a/docs/installation.md b/docs/installation.md index f4b02336..12bf8fb8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -36,6 +36,11 @@ python3 runme.py # (1)! 1. Newer systems may execute python instead of python3 +!!! note "This Should be All" + As long as you are on the recommended Raspberry Pi + OS, this should be all you need to execute for a complete setup. + This provided script will probably not properly work on other systems, since each OS may handle things differently. + If you are on another system, have a look into the other instructions, [faq](faq.md) or [troubleshooting](troubleshooting.md). + ## Installing Requirements The best way is to use the provided `requirements.txt` file. If Python is installed, just run: diff --git a/docs/pictures/Recipes_ui.png b/docs/pictures/Recipes_ui.png index a4d2ee0d..efe2ba50 100644 Binary files a/docs/pictures/Recipes_ui.png and b/docs/pictures/Recipes_ui.png differ diff --git a/docs/setup.md b/docs/setup.md index 0bc1d055..ed1fa27d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -26,34 +26,39 @@ If any of the values got a wrong data type, a ConfigError will be thrown with th Names starting with `EXP` are experimental and may be changed in the future. They can be used at own risk of CocktailBerry not working 100% properly. -| Value Name | Description | -| :--------------------------- | :----------------------------------------------------------------------------------- | -| `UI_DEVENVIRONMENT` | Enables some development features, like a cursor | -| `UI_PARTYMODE` | Protects other tabs than maker tab with a password | -| `UI_MASTERPASSWORD` | String for password, Use numbers for numpad like '1234' | -| `UI_LANGUAGE` | 2 char code for the language, see [supported languages](languages.md) | -| `UI_WIDTH` | Desired interface width, default is 800 | -| `UI_HEIGHT` | Desired interface height, default is 480 | -| `PUMP_PINS` | List of the [Pins](#configuring-the-pins-or-used-board) where each Pump is connected | -| `PUMP_VOLUMEFLOW` | List of the according volume flow for each pump in ml/s | -| `MAKER_BOARD` | Used [board](#configuring-the-pins-or-used-board) for Hardware | -| `MAKER_NAME` | Give your CocktailBerry an own name, max 30 chars | -| `MAKER_NUMBER_BOTTLES` | Number of displayed bottles, can use up to 16 bottles | -| `MAKER_SIMULTANEOUSLY_PUMPS` | Number of pumps which can be simultaneously active | -| `MAKER_SEARCH_UPDATES` | Search for updates at program start | -| `MAKER_PINS_INVERTED` | [Inverts](faq.md#what-is-the-inverted-option) pin signal (on=low, off=high) | -| `MAKER_THEME` | Choose which [theme](#themes) to use | -| `MAKER_CLEAN_TIME` | Time the machine will execute the cleaning program | -| `MAKER_SLEEP_TIME` | Interval between each time check while generating a cocktail | -| `MAKER_CHECK_INTERNET` | Do a connection check at start for time adjustment window | -| `MAKER_TUBE_VOLUME` | Volume in ml to pump up when bottle is set to new | -| `MICROSERVICE_ACTIVE` | Post to microservice set up by docker | -| `MICROSERVICE_BASE_URL` | Base URL for microservice (default: http://127.0.0.1:5000) | -| `TEAMS_ACTIVE` | Use teams feature | -| `TEAM_BUTTON_NAMES` | List of format ["Team1", "Team2"] | -| `TEAM_API_URL` | Endpoint of teams API, default used port by API is 8080 | -| `EXP_MAKER_UNIT` | Change the displayed unit in the maker tab (visual only\*) | -| `EXP_MAKER_FACTOR` | Multiply the displayed unit in the maker tab (visual only\*) | +| Value Name | Description | +| :--------------------------- | :--------------------------------------------------------------------------------------- | +| `UI_DEVENVIRONMENT` | Enables some development features, like a cursor | +| `UI_PARTYMODE` | Protects other tabs than maker tab with a password | +| `UI_MASTERPASSWORD` | String for password, Use numbers for numpad like '1234' | +| `UI_LANGUAGE` | 2 char code for the language, see [supported languages](languages.md) | +| `UI_WIDTH` | Desired interface width, default is 800 | +| `UI_HEIGHT` | Desired interface height, default is 480 | +| `PUMP_PINS` | List of the [Pins](#configuring-the-pins-or-used-board) where each Pump is connected | +| `PUMP_VOLUMEFLOW` | List of the according volume flow for each pump in ml/s | +| `MAKER_BOARD` | Used [board](#configuring-the-pins-or-used-board) for Hardware | +| `MAKER_NAME` | Give your CocktailBerry an own name, max 30 chars | +| `MAKER_NUMBER_BOTTLES` | Number of displayed bottles, can use up to 16 bottles | +| `MAKER_SIMULTANEOUSLY_PUMPS` | Number of pumps which can be simultaneously active | +| `MAKER_SEARCH_UPDATES` | Search for updates at program start | +| `MAKER_PINS_INVERTED` | [Inverts](faq.md#what-is-the-inverted-option) pin signal (on=low, off=high) | +| `MAKER_THEME` | Choose which [theme](#themes) to use | +| `MAKER_CLEAN_TIME` | Time the machine will execute the cleaning program | +| `MAKER_SLEEP_TIME` | Interval between each time check while generating a cocktail | +| `MAKER_CHECK_INTERNET` | Do a connection check at start for time adjustment window | +| `MAKER_TUBE_VOLUME` | Volume in ml to pump up when bottle is set to new | +| `LED_PINS` | List of pins connected to LEDs for preparation | +| `LED_BRIGHTNESS` | Brightness for the WS281x LED (1-255) | +| `LED_COUNT` | Number of LEDs on the WS281x | +| `LED_NUMBER_RINGS` | Number of IDENTICAL daisy chained WS281x LED rings | +| `LED_IS_WS` | Is the led a controllable WS281x LED, [see also](troubleshooting.md#get-the-led-working) | +| `MICROSERVICE_ACTIVE` | Post to microservice set up by docker | +| `MICROSERVICE_BASE_URL` | Base URL for microservice (default: http://127.0.0.1:5000) | +| `TEAMS_ACTIVE` | Use teams feature | +| `TEAM_BUTTON_NAMES` | List of format ["Team1", "Team2"] | +| `TEAM_API_URL` | Endpoint of teams API, default used port by API is 8080 | +| `EXP_MAKER_UNIT` | Change the displayed unit in the maker tab (visual only\*) | +| `EXP_MAKER_FACTOR` | Multiply the displayed unit in the maker tab (visual only\*) | \* You still need to provide the units in ml for the DB (recipes / ingredients). This is purely visual in the maker tab, at least for now. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3ed7bb4e..3a9bd9ee 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -87,6 +87,24 @@ This new option tackles that. If it's set active with an active microservice, it If there is no connection, a dialog will pop up and give the user the possibility to adjust the time. In case the machine got a RTC build in and uses it, this option can usually be set to `false`, because due to the RTC, the time should be correct. +## Get the LED Working + +Getting the WS281x to work may be a little bit tricky. +You MUST run the program as sudo (`sudo python runme.py`), so you also need to change this in `~/launcher.sh`. +If the GUI looks different than when you run it without sudo, try `sudo -E python runme.y` this should use your environment for Qt. +If you ran the program as non root, you will need to install the required python packages for the main program with sudo pip install. +Also, install the rpi_ws281x python package with: + +``` +sudo pip install rpi_ws281x +sudo pip install PyQt5 requests pyyaml GitPython typer pyfiglet qtawesome +``` + +See [here](https://github.com/jgarff/rpi_ws281x#gpio-usage) for a possible list and explanation for GPIOs. +I had success using the 12 and 18 PWM0 pin, while also disabling (use a # for comment) the line `#dtparam=audio=on` on `/boot/config.txt`. +Other described pins may also work, but are untested, so I recommend to stick to the both one that should work. +If you use any other non controllable LED connected over the relay, you can use any pin you want, since it's only activating the relay. + ## Ui Seems Wrong on none RaspOS System On different Linux systems (other than the recommended Raspbian OS), there may be differences in the look and functionality of the user interface. @@ -97,6 +115,14 @@ Please take note that CocktailBerry will run on other systems than the Raspberry Since I probably don't own that combination of Hardware and OS, you probably need to figure out that settings by yourself. If you are a unexperienced user with Linux, I recommend you stick to the recommended settings on a Pi. +## Task Bar Overlap / Push GUI + +This may happen (especially at older versions os RPi OS or higher res screens) when running the program and some dialog window opens. +The task bar (bar with programs on it) may overlap the dialog window or push it down by it's height. +Ensure that you have unchecked the "Reserve space, and not covered by maximised windows" option. +You can find it under the panel preferences (right click the task bar > panel settings > Advanced). +Unchecking this box usually fixes this problem. + ## Problems Installing Software on Raspberry Pi The Raspberry Pi can sometimes differ from other machines in terms of installation. Here are some issues that might occur. @@ -162,6 +188,7 @@ sudo chmod 755 ~/launcher.sh I've noticed when running as root (sudo python3) and running as the pi user (python3) by default the pi will use different GUI resources. Using the pi user will result in the shown interfaces at CocktailBerry (and the program should work without root privilege). Setting the XDG_RUNTIME_DIR to use the qt5ct plugin may also work but is untested. +Using the users environment with `sudo -E python runme.py` should also do the trick. ### Some Python Things do not Work diff --git a/microservice/app.py b/microservice/app.py index e56c9711..54f08141 100644 --- a/microservice/app.py +++ b/microservice/app.py @@ -8,9 +8,9 @@ import requests from dotenv import load_dotenv from flask import Flask, request, abort, jsonify +from flask.logging import create_logger -from querry_sender import try_send_querry_data -from email_sender import send_mail +from query_sender import try_send_query_data from database import DatabaseHandler from helper import generate_urls_and_headers @@ -18,6 +18,7 @@ app = Flask(__name__) logging.basicConfig(level=logging.INFO) +_logger = create_logger(app) @app.route("/") @@ -27,21 +28,21 @@ def welcome(): @app.route("/hookhandler/cocktail", methods=["POST"]) def post_cocktail_hook(): - def post_to_hook(url: str, payload: str, headers: Dict, send_querry: bool): + def post_to_hook(url: str, payload: str, headers: Dict, send_query: bool): try: - req = requests.post(url, data=payload, headers=headers) - app.logger.info(f"{req.status_code}: Posted to {url} with payload: {payload}") + req = requests.post(url, data=payload, headers=headers, timeout=10) + _logger.info("%s: Posted to %s with payload: %s", req.status_code, url, payload) # Check if there is still querries data which was not send previously # Needs to be specified to send, since multiple threads would cause double sending - if send_querry: - try_send_querry_data(app) + if send_query: + try_send_query_data(app) except requests.exceptions.ConnectionError: - app.logger.error(f"Could not connect to {url} for the cocktail data!") + _logger.error("Could not connect to %s for the cocktail data!", url) db_handler = DatabaseHandler() db_handler.save_failed_post(payload, url, headers) # pylint: disable=broad-except except Exception as err: - app.logger.error(f"Some other error occured: {err}") + _logger.error("Some other error occurred: %s", err) if not request.json or "cocktailname" not in request.json: abort(400) @@ -59,26 +60,27 @@ def post_to_hook(url: str, payload: str, headers: Dict, send_querry: bool): return jsonify({"text": "No endpoints activated"}), 201 for pos, (url, headers) in enumerate(endpoint_data): - send_querry = pos == 0 - thread = Thread(target=post_to_hook, args=(url, payload, headers, send_querry,)) + send_query = pos == 0 + thread = Thread(target=post_to_hook, args=(url, payload, headers, send_query,)) thread.start() return jsonify({"text": "Post to cocktail webhook started"}), 201 -@app.route("/email", methods=["POST"]) +@app.route("/data-export", methods=["POST"]) def post_file_with_mail(): data_file = request.files["upload_file"] - text = send_mail(data_file.filename, data_file) - app.logger.info(text) + # TODO: Implement new sender / Endpoint + text = f"Not implemented sending data. Datatype is {type(data_file)}" + _logger.info(text) return jsonify({"text": text}), 200 @app.route("/debug", methods=["POST"]) def debug_ep(): - app.logger.info(request.json) + _logger.info(request.json) return jsonify({"text": "debug"}), 200 if __name__ == "__main__": - try_send_querry_data(app) + try_send_query_data(app) app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5000"))) diff --git a/microservice/database.py b/microservice/database.py index 8f4bb279..3f879a2d 100644 --- a/microservice/database.py +++ b/microservice/database.py @@ -32,8 +32,8 @@ def delete_failed_by_id(self, data_id: int): sql = "DELETE FROM Querry WHERE ID = ?" self.query_database(sql, (data_id,)) - def query_database(self, sql: str, serachtuple=()): - self.cursor.execute(sql, serachtuple) + def query_database(self, sql: str, serach_tuple=()): + self.cursor.execute(sql, serach_tuple) if sql[0:6].lower() == "select": result = self.cursor.fetchall() else: diff --git a/microservice/email_sender.py b/microservice/email_sender.py deleted file mode 100644 index eda6a9bd..00000000 --- a/microservice/email_sender.py +++ /dev/null @@ -1,47 +0,0 @@ -import smtplib -import ssl -import os -import json -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.mime.nonmultipart import MIMENonMultipart -from email.utils import formatdate -from email import encoders - - -def send_mail(file_name: str, file_to_send): - sender_address = os.getenv("SENDER_ADDRESS") - sender_pass = os.getenv("SENDER_PASSWORD") - receiver_address = os.getenv("RECEIVER_ADRESS") - context = ssl.create_default_context() - - mail_content = ( - "Hello CocktailBerry Owner,\n\n" - f"As you have activated the sending of the export data via email, here is the {file_name} :) \n\n" - "Enjoy the data!\n" - "Your local CocktailBerry" - ) - - message = MIMEMultipart() - message["From"] = sender_address - message["To"] = receiver_address - message["Cc"] = sender_address - message["Subject"] = f"Your CocktailBerry Data: {file_name}" - message["Date"] = formatdate(localtime=True) - message.attach(MIMEText(mail_content, "plain")) - - attachment = MIMENonMultipart("text", "csv", charset="utf-8") - attachment.add_header("Content-Disposition", "attachment", filename=file_name) - attachment.set_payload(file_to_send.read()) - encoders.encode_base64(attachment) - message.attach(attachment) - - session = smtplib.SMTP("smtp.gmail.com", 587) - session.ehlo() - session.starttls(context=context) - session.ehlo() - session.login(sender_address, sender_pass) - text = message.as_string() - send_res = session.sendmail(sender_address, receiver_address, text) - session.quit() - return f"Sending from {sender_address} to {receiver_address} Information from sendmail: {json.dumps(send_res)}" diff --git a/microservice/helper.py b/microservice/helper.py index 87570c70..249ba730 100644 --- a/microservice/helper.py +++ b/microservice/helper.py @@ -3,12 +3,12 @@ DEFAULT_HOOK_EP = "enpointforhook" DEFAULT_API_KEY = "readdocshowtoget" -API_ENDPOINT = "https://cberry.deta.dev/cocktail" +API_ENDPOINT = "https://cocktailberryapi-1-u0613408.deta.app/cocktail" def generate_urls_and_headers() -> List[Tuple[str, Dict[str, str]]]: """Generates the urls as well as the header from the .env data""" - hookurl = os.getenv("HOOK_ENDPOINT") + hook_url = os.getenv("HOOK_ENDPOINT") api_key = os.getenv("API_KEY") hook_headers_config = os.getenv("HOOK_HEADERS", None) if hook_headers_config is None: @@ -23,13 +23,13 @@ def generate_urls_and_headers() -> List[Tuple[str, Dict[str, str]]]: hook_headers = dict([x.split(":") for x in headers]) api_headers = { "content-type": "application/json", - "X-API-Key": api_key, + "X-Space-App-Key": api_key, } - use_hook = (hookurl != DEFAULT_HOOK_EP) and (hookurl is not None) + use_hook = (hook_url != DEFAULT_HOOK_EP) and (hook_url is not None) use_api = (api_key != DEFAULT_API_KEY) and (api_key is not None) endpoint_data = [] if use_hook: - endpoint_data.append((hookurl, hook_headers,)) + endpoint_data.append((hook_url, hook_headers,)) if use_api: endpoint_data.append((API_ENDPOINT, api_headers,)) diff --git a/microservice/querry_sender.py b/microservice/query_sender.py similarity index 87% rename from microservice/querry_sender.py rename to microservice/query_sender.py index d54bcc4e..c6af42a9 100644 --- a/microservice/querry_sender.py +++ b/microservice/query_sender.py @@ -4,7 +4,7 @@ from database import DatabaseHandler -def try_send_querry_data(app: Flask): +def try_send_query_data(app: Flask): db_handler = DatabaseHandler() failed_data = db_handler.get_failed_data() # Return if nothing to do @@ -14,14 +14,14 @@ def try_send_querry_data(app: Flask): app.logger.info("Found some not sended data, trying to send ...") for send_id, data, url, headers in failed_data: try: - res = requests.post(url, data=data, headers=json.loads(headers)) + res = requests.post(url, data=data, headers=json.loads(headers), timeout=10) app.logger.info(f"Code: {res.status_code}, to: {url}, Payload: {data}") except requests.exceptions.ConnectionError: app.logger.error("There is still no connection") return # pylint: disable=broad-except except Exception as err: - app.logger.error(f"Some other error occured: {err}") + app.logger.error(f"Some other error occurred: {err}") return # if send successfully, delete this entry else: diff --git a/pyproject.toml b/pyproject.toml index 40049374..152b6c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "CocktailBerry" -version = "1.14.0" +version = "1.15.0" description = "A Python and Qt based App for a Cocktail Machine on a Raspberry Pi. Easily serve Cocktails with Raspberry Pi and Python" authors = ["Andre Wohnsland "] readme = "readme.md" diff --git a/readme.md b/readme.md index 025bfae8..d26cfbcd 100644 --- a/readme.md +++ b/readme.md @@ -43,6 +43,7 @@ CocktailBerry can do: - Keep track of cocktail count and volume from different teams for some fun competition - Select different themes to fit your liking - Switch between user interface languages +- Support WS281x LEDs on your machine In addition, there is the possibility to use and set up a second device as a dashboard: diff --git a/scripts/cocktail.desktop b/scripts/cocktail.desktop index 39e9725a..8698c99f 100644 --- a/scripts/cocktail.desktop +++ b/scripts/cocktail.desktop @@ -2,4 +2,4 @@ Type=Application Name=CocktailScreen NoDisplay=false -Exec=/usr/bin/lxterminal -e /home/pi/launcher.sh \ No newline at end of file +Exec=/usr/bin/lxterminal -e "~/launcher.sh; read -p 'Press Enter to Close'"" \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index 9647e56e..a76ef21f 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,8 +1,8 @@ -__version__ = "1.14.0" +__version__ = "1.15.0" PROJECT_NAME = "CocktailBerry" MAX_SUPPORTED_BOTTLES = 16 SUPPORTED_LANGUAGES = ["en", "de"] SUPPORTED_BOARDS = ["RPI", "Generic"] SUPPORTED_THEMES = ["default", "bavaria", "alien", "berry"] -NEEDED_PYTHON_VERSION = (3, 7) +NEEDED_PYTHON_VERSION = (3, 9) FUTURE_PYTHON_VERSION = (3, 9) diff --git a/src/config_manager.py b/src/config_manager.py index 373b9084..f3894108 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -81,6 +81,16 @@ class ConfigManager: MAKER_CHECK_INTERNET = True # Volume to pump up if a bottle gets changed MAKER_TUBE_VOLUME = 0 + # List of LED pins for control + LED_PINS = [] + # Value for LED brightness + LED_BRIGHTNESS = 255 + # Number of LEDs, only important for controllable + LED_COUNT = 24 + # If there are multiple identical (ring) LEDs + LED_NUMBER_RINGS = 1 + # If the led is as ws-x series (and controllable) + LED_IS_WS = True # If to use microservice (mostly docker on same device) to handle external API calls and according url MICROSERVICE_ACTIVE = False MICROSERVICE_BASE_URL = "http://127.0.0.1:5000" @@ -123,6 +133,11 @@ def __init__(self) -> None: "MAKER_THEME": (ThemeChoose, [_build_support_checker(SUPPORTED_THEMES)]), "MAKER_CHECK_INTERNET": (bool, []), "MAKER_TUBE_VOLUME": (int, [_build_number_limiter(0, 50)]), + "LED_PINS": (list, [self._validate_config_list_type]), + "LED_BRIGHTNESS": (int, [_build_number_limiter(1, 255)]), + "LED_COUNT": (int, [_build_number_limiter(1, 500)]), + "LED_NUMBER_RINGS": (int, [_build_number_limiter(1, 10)]), + "LED_IS_WS": (bool, []), "MICROSERVICE_ACTIVE": (bool, []), "MICROSERVICE_BASE_URL": (str, []), "TEAMS_ACTIVE": (bool, []), @@ -137,6 +152,7 @@ def __init__(self) -> None: "PUMP_PINS": (int, [self._validate_pin_numbers]), "PUMP_VOLUMEFLOW": (int, [_build_number_limiter(1, 1000)]), "TEAM_BUTTON_NAMES": (str, []), + "LED_PINS": (int, [_build_number_limiter(0, 200)]), } try: self._read_config() @@ -278,6 +294,7 @@ def version_callback(value: bool): """Returns the version of the program""" if value: typer.echo(f"{PROJECT_NAME} Version {__version__}. Created by Andre Wohnsland.") + typer.echo(get_platform_data()) typer.echo(r"For more information visit the docs: https://cocktailberry.readthedocs.io") typer.echo(r"Or the GitHub: https://github.com/AndreWohnsland/CocktailBerry.") raise typer.Exit() diff --git a/src/dialog_handler.py b/src/dialog_handler.py index 71333e22..bb3c808a 100644 --- a/src/dialog_handler.py +++ b/src/dialog_handler.py @@ -2,10 +2,14 @@ # pylint: disable=import-outside-toplevel from pathlib import Path +import time from typing import Dict, List, Optional, Literal +from threading import Thread, Event import yaml from PyQt5.QtWidgets import QFileDialog, QWidget from src.config_manager import CONFIG as cfg +from src.utils import get_platform_data +from src import __version__ DIRPATH = Path(__file__).parent.absolute() @@ -27,15 +31,30 @@ def __choose_language(self, element_name: str, **kwargs) -> str: tmpl = element.get(language, element["en"]) return tmpl.format(**kwargs) - def standard_box(self, message: str, title: str = "", use_ok=False): + def standard_box(self, message: str, title: str = "", use_ok=False, close_time: Optional[int] = None): """ The default messagebox for the Maker. Uses a Custom QDialog with Close-Button """ from src.ui.setup_custom_dialog import CustomDialog + + def close_thread(event: Event, box: CustomDialog, close_time: int): + """Function to control auto close""" + time.sleep(close_time) + if event.is_set(): + return + box.close() + if not title: title = self.__choose_language("box_title") fill_string = "-" * 70 fancy_message = f"{fill_string}\n{message}\n{fill_string}" messagebox = CustomDialog(fancy_message, title, self.icon_path, use_ok) + event = Event() + # If there is a close time, start auto close + if close_time is not None: + auto_closer = Thread(target=close_thread, args=(event, messagebox, close_time), daemon=True) + auto_closer.start() messagebox.exec_() + # Need to set event, in case thread is still waiting + event.set() def user_okay(self, text: str): from src.ui.setup_custom_prompt import CustomPrompt @@ -52,9 +71,9 @@ def password_prompt(self): return True return False - def __output_language_dialog(self, dialog_name: str, use_ok=False, **kwargs): + def __output_language_dialog(self, dialog_name: str, use_ok=False, close_time: Optional[int] = None, **kwargs): msg = self.__choose_language(dialog_name, **kwargs) - self.standard_box(msg, use_ok=use_ok) + self.standard_box(msg, use_ok=use_ok, close_time=close_time) def _get_folder_location(self, w: QWidget, message: str): return QFileDialog.getExistingDirectory(w, message) @@ -133,7 +152,7 @@ def say_ingredient_added_or_changed( def say_cocktail_canceled(self): """Informs user that the cocktail was canceled""" - self.__output_language_dialog("cocktail_canceled") + self.__output_language_dialog("cocktail_canceled", close_time=10) def say_cocktail_ready(self, comment: str): """Informs user that the cocktail is done with additional information what to add""" @@ -141,7 +160,7 @@ def say_cocktail_ready(self, comment: str): if comment: header_comment = self.__choose_language("cocktail_ready_add") full_comment = f"\n\n{header_comment}{comment}" - self.__output_language_dialog("cocktail_ready", full_comment=full_comment) + self.__output_language_dialog("cocktail_ready", close_time=60, full_comment=full_comment) def say_enter_cocktail_name(self): """Informs user that no cocktail name was supplied""" @@ -222,6 +241,13 @@ def say_python_deprecated(self, sys_python: str, program_python: str): program_python=program_python ) + def say_welcome_message(self): + self.__output_language_dialog( + "welcome_dialog", + version=__version__, + platform=get_platform_data() + ) + def ask_to_update(self, release_information): """Asks the user if he wants to get the latest update""" message = self.__choose_language("update_available") @@ -312,7 +338,7 @@ def get_config_description(self, config_name: str) -> str: try: return self.__choose_language(config_name, "settings_dialog") # if there is nothing for this settings, we will get an attribute error - except AttributeError: + except (AttributeError, KeyError): return "" def adjust_mainwindow(self, w): diff --git a/src/language.yaml b/src/language.yaml index ab7d8668..0ec3c4b2 100644 --- a/src/language.yaml +++ b/src/language.yaml @@ -146,11 +146,14 @@ dialog: en: 'There seems to be no internet connection, your date/time may be wrong. This is important when sending data with the service. Do you want to adjust time?' de: 'Es scheint keine Internetverbindung zu bestehen, Datum/Zeit könnte falsch sein. Dies ist wichtig, wenn Daten über den Service versandt werden. Zeit anpassen?' python_deprecated: - en: 'Your system Python is {sys_python}. In the future, {program_python} is required. Please upgrade, otherwise a future CocktailBerry update will break your program.' - de: 'Die Python Version des Systems is {sys_python}. In Zukunft wird {program_python} benötigt. Bitte upgrade diese bald, sonst wird mit einem kommenden Update CocktailBerry nicht mehr funktionieren.' + en: 'Your system Python is {sys_python}. In the future, {program_python} is required. Please upgrade, otherwise a future CocktailBerry update will break your program. Also, the auto update function will not show new updates until then.' + de: 'Die Python Version des Systems is {sys_python}. In Zukunft wird {program_python} benötigt. Bitte upgrade diese bald, sonst wird mit einem kommenden Update CocktailBerry nicht mehr funktionieren. Die Autoupdate Funktion wird solange keine neuen Updates anzeigen.' ask_export_data: en: 'Export cocktail and ingredient data, as well reset possible values?' de: 'Cocktail und Zutaten Daten exportieren und mögliche Werte zurücksetzen?' + welcome_dialog: + en: "CocktailBerry Software Version {version} by Andre Wohnsland.\n{platform}.\n\nCheck https://cocktailberry.readthedocs.io for more information." + de: "CocktailBerry Software Version {version} von Andre Wohnsland.\n{platform}.\n\nMehr Informationen unter https://cocktailberry.readthedocs.io." # Change of language in UI elements ui: @@ -415,6 +418,21 @@ ui: MAKER_TUBE_VOLUME: en: 'Empty tube volume from bottle to pump' de: 'Leeres Schlauchvolumen von Flasche zu Pumpe' + LED_PINS: + en: 'List of pins controlling LEDs during preparation' + de: 'Liste an Pins, die LEDs bei der Zubereitung kontrollieren' + LED_BRIGHTNESS: + en: 'Brightness value (1-255) for the WS281x LEDs' + de: 'Helligkeitswert (1-255) für die WS281x LEDs' + LED_COUNT: + en: 'Number of LEDs on the WS281x / LEDs per ring, if IDENTICAL daisy chained.' + de: 'Anzahl der LEDs an dem WS281x / LEDs pro Ring, wenn IDENTISCHEN in Serie verbunden.' + LED_NUMBER_RINGS: + en: 'Number of IDENTICAL daisy chained ring LEDs. Use 1 if different LED count per ring.' + de: 'Anzahl der IDENTISCHEN in Serie verbundene Ring LEDs. Verwende 1, wenn verschiedene LED Anzahl pro Ring.' + LED_IS_WS: + en: 'Is the LED a controllable (WS281x) LED' + de: 'Ist der LED ein ansteuerbarer (WS281x) LED' MICROSERVICE_ACTIVE: en: 'Activates the interaction with the microservice' de: 'Aktiviert die Interaktion mit dem Microservice' diff --git a/src/machine/controller.py b/src/machine/controller.py index 13c19dc9..e211ecd2 100644 --- a/src/machine/controller.py +++ b/src/machine/controller.py @@ -9,6 +9,7 @@ from src.machine.generic_board import GenericController from src.machine.interface import PinController from src.machine.raspberry import RpiController +from src.machine.leds import LedController if TYPE_CHECKING: from src.ui.setup_mainwindow import MainScreen @@ -29,6 +30,7 @@ class MachineController(): def __init__(self): super().__init__() self._pin_controller = self._chose_controller() + self._led_controller = LedController(self._pin_controller) def _chose_controller(self) -> PinController: """Selects the controller class for the Pin""" @@ -79,7 +81,9 @@ def make_cocktail( w.open_progression_window(recipe) prep_data = _build_preparation_data(bottle_list, volume_list) _header_print(f"Starting {recipe}") + self._led_controller.preparation_start() current_time, max_time = self._start_preparation(w, prep_data, verbose) + self._led_controller.preparation_end() consumption = [round(x.consumption) for x in prep_data] print("Total calculated consumption:", consumption) _header_print(f"Finished {recipe}") diff --git a/src/machine/generic_board.py b/src/machine/generic_board.py index 6a262096..cb8504b2 100644 --- a/src/machine/generic_board.py +++ b/src/machine/generic_board.py @@ -17,17 +17,22 @@ class GenericController(PinController): """Controller class to control pins on a generic board""" def __init__(self, inverted: bool) -> None: - super().__init__(inverted) + super().__init__() + self.inverted = inverted self.devenvironment = DEV self.low = False self.high = True if inverted: self.low, self.high = self.high, self.low self.gpios: dict[int, GPIO] = {} + self.dev_displayed = False def initialize_pin_list(self, pin_list: List[int]): """Set up the given pin list""" - print(f"Devenvironment on the Generic Pin Control module is {'on' if self.devenvironment else 'off'}") + if not self.dev_displayed: + print(f"Devenvironment on the Generic Pin Control module is {'on' if self.devenvironment else 'off'}") + self.dev_displayed = True + init_value = "high" if self.inverted else "out" if not self.devenvironment: for pin in pin_list: diff --git a/src/machine/interface.py b/src/machine/interface.py index 51468f9b..247f3562 100644 --- a/src/machine/interface.py +++ b/src/machine/interface.py @@ -10,10 +10,6 @@ class PinController(Protocol): # type: ignore """Interface to control the pins""" - - def __init__(self, inverted: bool): - self.inverted = inverted - @abstractmethod def initialize_pin_list(self, pin_list: List[int]): raise NotImplementedError diff --git a/src/machine/leds.py b/src/machine/leds.py new file mode 100644 index 00000000..2a5abfdb --- /dev/null +++ b/src/machine/leds.py @@ -0,0 +1,210 @@ +from threading import Thread +import time +from typing import Protocol +from abc import abstractmethod +from random import randint +from src.config_manager import CONFIG as cfg +from src.logger_handler import LoggerHandler +from src.machine.interface import PinController + +_logger = LoggerHandler("LedController") + +try: + # pylint: disable=import-error + from rpi_ws281x import Adafruit_NeoPixel, Color # type: ignore + MODULE_AVAILABLE = True +except ModuleNotFoundError: + MODULE_AVAILABLE = False + + +class LedController: + def __init__(self, pin_controller: PinController) -> None: + self.pin_controller = pin_controller + self.pins = cfg.LED_PINS + enabled = len(cfg.LED_PINS) > 0 + self.controllable = cfg.LED_IS_WS and MODULE_AVAILABLE + self.led_list: list[_LED] = [] + if enabled and cfg.LED_IS_WS and not MODULE_AVAILABLE: + _logger.log_event( + "ERROR", + "Could not import rpi_ws281x. Will not be able to control the WS281x, please install the library." + ) + return + # If not controllable use normal LEDs + if not cfg.LED_IS_WS: + self.led_list: list[_LED] = [ + _normalLED(pin, self.pin_controller) + for pin in self.pins + ] + return + # If controllable try to set up the WS281x LEDs + try: + self.led_list: list[_LED] = [ + _controllableLED(pin) + for pin in self.pins + ] + # Will be thrown if ws281x module init (.begin()) as none root + except RuntimeError: + _logger.log_event( + "ERROR", + "Could not set up the WS281x, is the program running as root?" + ) + + def preparation_start(self): + for led in self.led_list: + led.preparation_start() + + def preparation_end(self, duration: int = 5): + for led in self.led_list: + led.preparation_end(duration) + + +class _LED(Protocol): + @abstractmethod + def preparation_start(self) -> None: + raise NotImplementedError + + @abstractmethod + def preparation_end(self, duration: int) -> None: + raise NotImplementedError + + +class _normalLED(_LED): + def __init__(self, pin: int, pin_controller: PinController) -> None: + self.pin = pin + self.pin_controller = pin_controller + self.pin_controller.initialize_pin_list([self.pin]) + + def __del__(self): + self.pin_controller.cleanup_pin_list([self.pin]) + + def _turn_on(self): + """Turns the LEDs on""" + self.pin_controller.activate_pin_list([self.pin]) + + def _turn_off(self): + """Turns the LEDs off""" + self.pin_controller.close_pin_list([self.pin]) + + def preparation_start(self): + """Turn the LED on during preparation""" + self._turn_on() + + def preparation_end(self, duration: int = 5): + """Blink for some time after preparation""" + self._turn_off() + blinker = Thread(target=self._blink_for, kwargs={"duration": duration}) + blinker.daemon = True + blinker.start() + + def _blink_for(self, duration: int = 5, interval: float = 0.2): + current_time = 0 + step = interval / 2 + while current_time <= duration: + self._turn_on() + time.sleep(step) + current_time += step + self._turn_off() + time.sleep(step) + current_time += step + + +class _controllableLED(_LED): + def __init__(self, pin: int) -> None: + self.pin = pin + self.strip = Adafruit_NeoPixel( + cfg.LED_COUNT * cfg.LED_NUMBER_RINGS, + pin, # best to use 12 or 18 + 800000, # freq + 10, # DMA 5 / 10 + False, # invert + cfg.LED_BRIGHTNESS, # brightness + 0 # channel 0 or 1 + ) + # will throw a RuntimeError as none root user here + self.strip.begin() + self.is_preparing = False + + def _preparation_thread(self): + """Fills one by one with same random color, then repeats / overwrites old ones""" + wait_ms = 40 + self.turn_on(Color(randint(0, 255), randint(0, 255), randint(0, 255))) + while self.is_preparing: + color = Color( + randint(0, 255), + randint(0, 255), + randint(0, 255), + ) + for i in range(cfg.LED_COUNT): + # If multiple identical ring LEDs operate them simultaneously + for k in range(0, cfg.LED_NUMBER_RINGS): + iter_pos = k * cfg.LED_COUNT + i + self.strip.setPixelColor(iter_pos, color) + # Turn of 2 leading LEDs to have a more spinner like light effect + of_pos = iter_pos + 1 if i != cfg.LED_COUNT - 1 else 0 + k * cfg.LED_COUNT + of_pos2 = iter_pos + 2 if i != cfg.LED_COUNT - 2 else 0 + k * cfg.LED_COUNT + self.strip.setPixelColor(of_pos, Color(0, 0, 0)) + self.strip.setPixelColor(of_pos2, Color(0, 0, 0)) + self.strip.show() + time.sleep(wait_ms / 1000) + + def turn_off(self): + """Turns all leds off""" + for k in range(0, cfg.LED_NUMBER_RINGS): + for i in range(0, cfg.LED_COUNT): + iter_pos = k * cfg.LED_COUNT + i + self.strip.setPixelColor(iter_pos, Color(0, 0, 0)) + self.strip.show() + + def turn_on(self, color): + """Turns all leds on to given color""" + for k in range(0, cfg.LED_NUMBER_RINGS): + for i in range(0, cfg.LED_COUNT): + iter_pos = k * cfg.LED_COUNT + i + self.strip.setPixelColor(iter_pos, color) + self.strip.show() + + def _wheel(self, pos: int): + """Generate rainbow colors across 0-255 positions.""" + if pos < 85: + return Color(pos * 3, 255 - pos * 3, 0) + if pos < 170: + pos -= 85 + return Color(255 - pos * 3, 0, pos * 3) + pos -= 170 + return Color(0, pos * 3, 255 - pos * 3) + + def _end_thread(self, duration: int = 5): + """Rainbow animation fades across all pixels at once""" + wait_ms = 10 + current_time = 0 + wheel_order = range(256) + start = randint(0, 255) + wheel_order = list(wheel_order[start::]) + list(wheel_order[0:start]) + while current_time <= duration: + for j in wheel_order: + for i in range(cfg.LED_COUNT): + for k in range(0, cfg.LED_NUMBER_RINGS): + iter_pos = k * cfg.LED_COUNT + i + self.strip.setPixelColor(iter_pos, self._wheel((i + j) & 255)) + self.strip.show() + time.sleep(wait_ms / 1000.0) + current_time += wait_ms / 1000 + # break out of loop (its long) when we are finished + if current_time > duration: + break + self.turn_off() + + def preparation_start(self): + """Effect during preparation""" + self.is_preparing = True + cycler = Thread(target=self._preparation_thread) + cycler.daemon = True + cycler.start() + + def preparation_end(self, duration: int = 5): + """Plays an effect after the preparation for x seconds""" + self.is_preparing = False + rainbow = Thread(target=self._end_thread, kwargs={"duration": duration}) + rainbow.daemon = True + rainbow.start() diff --git a/src/machine/raspberry.py b/src/machine/raspberry.py index e0e3b75c..b1f3da93 100644 --- a/src/machine/raspberry.py +++ b/src/machine/raspberry.py @@ -19,20 +19,24 @@ class RpiController(PinController): """Controller class to control Raspberry Pi pins""" def __init__(self, inverted: bool) -> None: - super().__init__(inverted) + super().__init__() + self.inverted = inverted self.devenvironment = DEV self.low = GPIO.LOW if not DEV else 0 self.high = GPIO.HIGH if not DEV else 1 if inverted: self.low, self.high = self.high, self.low + self.dev_displayed = False def initialize_pin_list(self, pin_list: List[int]): """Set up the given pin list""" - print(f"Devenvironment on the RPi module is {'on' if self.devenvironment else 'off'}") + if not self.dev_displayed: + print(f"Devenvironment on the RPi module is {'on' if self.devenvironment else 'off'}") + self.dev_displayed = True if not self.devenvironment: GPIO.setup(pin_list, GPIO.OUT, initial=self.low) else: - logger.log_event("WARNING", "Could not import RPi.GPIO. Will not be able to control pins") + logger.log_event("WARNING", f"Could not import RPi.GPIO. Will not be able to control pins: {pin_list}") def activate_pin_list(self, pin_list: List[int]): """Activates the given pin list""" diff --git a/src/migration/migrator.py b/src/migration/migrator.py index e48a21b5..4ae13896 100644 --- a/src/migration/migrator.py +++ b/src/migration/migrator.py @@ -1,4 +1,5 @@ # pylint: disable=wrong-import-order,wrong-import-position,too-few-public-methods,ungrouped-imports +import platform from src.python_vcheck import check_python_version # Version check takes place before anything, else other imports may throw an error check_python_version() @@ -83,11 +84,14 @@ def make_migrations(self): self._check_local_version_data() def _python_to_old_warning(self, least_python: Tuple[int, int]): - if sys.version_info < least_python: - pv_format = f"Python {least_python[0]}.{least_python[1]}" - release_version_notes = f"v{self.program_version.major}.{self.program_version.minor}" - _logger.log_event("WARNING", f"Your used Python is deprecated, please upgrade to {pv_format} or higher") - _logger.log_event("WARNING", f"Please read the release notes {release_version_notes} for more information") + sys_python = sys.version_info + if sys_python < least_python: + future_format = f"Python {least_python[0]}.{least_python[1]}" + sys_format = f"{platform.python_version()}" + _logger.log_event( + "WARNING", + f"Your used Python ({sys_format}) is deprecated, please upgrade to {future_format} or higher" + ) def _check_local_version_data(self): """Checks to update the local version data""" diff --git a/src/programs/cli.py b/src/programs/cli.py index 410aadbc..bef42cec 100644 --- a/src/programs/cli.py +++ b/src/programs/cli.py @@ -20,7 +20,8 @@ def main( ctx: typer.Context, calibration: bool = typer.Option(False, "--calibration", "-c", help="Run the calibration program."), debug: bool = typer.Option(False, "--debug", "-d", help="Using debug instead of normal Endpoints."), - version: Optional[bool] = typer.Option(None, "--version", callback=version_callback, help="Show current version.") + version: Optional[bool] = typer.Option( + None, "--version", "-V", callback=version_callback, help="Show current version.") ): """ Starts the cocktail program. Optional, can start the calibration program. diff --git a/src/service_handler.py b/src/service_handler.py index 7170afa2..e5850543 100644 --- a/src/service_handler.py +++ b/src/service_handler.py @@ -27,7 +27,7 @@ def __init__(self): def post_cocktail_to_hook(self, cocktail_name: str, cocktail_volume: int, cocktail_object: Cocktail) -> Dict: """Post the given cocktail data to the microservice handling internet traffic to send to defined webhook""" if not cfg.MICROSERVICE_ACTIVE: - return service_disabled() + return _service_disabled() # Extracts the volume and name from the ingredient objects ingredient_data = [{"name": i.name, "volume": i.amount} for i in cocktail_object.adjusted_ingredients] data = { @@ -39,23 +39,26 @@ def post_cocktail_to_hook(self, cocktail_name: str, cocktail_volume: int, cockta } payload = json.dumps(data) endpoint = self._decide_debug_endpoint(f"{self.base_url}/hookhandler/cocktail") - return self.__try_to_send(endpoint, PostType.COCKTAIL, payload=payload) + return self._try_to_send(endpoint, PostType.COCKTAIL, payload=payload) - def send_mail(self, file_name: str, binary_file) -> Dict: - """Post the given file to the microservice handling internet traffic to send as mail""" + def send_export_data(self, file_name: str, binary_file, is_disabled=True) -> Dict: + """Post the given file to the microservice handling internet traffic to send data to external source""" if not cfg.MICROSERVICE_ACTIVE: - return service_disabled() - endpoint = self._decide_debug_endpoint(f"{self.base_url}/email") + return _service_disabled() + endpoint = self._decide_debug_endpoint(f"{self.base_url}/data-export") files = {"upload_file": (file_name, binary_file,)} - return self.__try_to_send(endpoint, PostType.FILE, files=files) + # Currently not configured + if is_disabled: + return _service_disabled() + return self._try_to_send(endpoint, PostType.FILE, files=files) def post_team_data(self, team_name: str, cocktail_volume: int) -> Dict: """Post the given team name to the team api if activated""" if not cfg.TEAMS_ACTIVE: - return team_disabled() + return _team_disabled() payload = json.dumps({"team": team_name, "volume": cocktail_volume}) endpoint = self._decide_debug_endpoint(f"{cfg.TEAM_API_URL}/cocktail") - return self.__try_to_send(endpoint, PostType.TEAMDATA, payload=payload) + return self._try_to_send(endpoint, PostType.TEAMDATA, payload=payload) def _decide_debug_endpoint(self, endpoint: str): """Checks if to use the given or the debug ep""" @@ -64,7 +67,7 @@ def _decide_debug_endpoint(self, endpoint: str): return f"{self.base_url}/debug" return endpoint - def __try_to_send( + def _try_to_send( self, endpoint: str, post_type: PostType, @@ -89,12 +92,12 @@ def __try_to_send( """ try: if payload is not None: - req = requests.post(endpoint, data=payload, headers=self.headers, timeout=3) + req = requests.post(endpoint, data=payload, headers=self.headers, timeout=2) # if successfully send to teams, see if there are other to send. if post_type is PostType.TEAMDATA: - self.__check_failed_data() + self._check_failed_data() elif files is not None: - req = requests.post(endpoint, files=files, timeout=3) + req = requests.post(endpoint, files=files, timeout=2) else: raise ValueError('Neither payload nor files given!') message = str(req.text).replace("\n", "") @@ -104,16 +107,16 @@ def __try_to_send( "message": message, } except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): - self.__log_connection_error(endpoint, post_type) + self._log_connection_error(endpoint, post_type) # only save failed team data for now if post_type is PostType.TEAMDATA: DB_COMMANDER.save_failed_teamdata(payload) return {} - def __log_connection_error(self, endpoint: str, post_type: PostType): + def _log_connection_error(self, endpoint: str, post_type: PostType): self.logger.log_event("ERROR", f"Could not connect to: '{endpoint}' for {post_type.value}") - def __check_failed_data(self): + def _check_failed_data(self): """Gets one failed teamdata and sends it""" endpoint = f"{cfg.TEAM_API_URL}/cocktail" failed_data = DB_COMMANDER.get_failed_teamdata() @@ -121,10 +124,10 @@ def __check_failed_data(self): msg_id, payload = failed_data # Delete the old thing before recursion hell comes live DB_COMMANDER.delete_failed_teamdata(msg_id) - self.__try_to_send(endpoint, PostType.TEAMDATA, payload) + self._try_to_send(endpoint, PostType.TEAMDATA, payload) -def service_disabled(): +def _service_disabled(): """Return that microservice is disabled""" return { "status": 503, @@ -132,7 +135,7 @@ def service_disabled(): } -def team_disabled(): +def _team_disabled(): """Return that teams is disabled""" return { "status": 503, diff --git a/src/ui/setup_mainwindow.py b/src/ui/setup_mainwindow.py index 5129e6ef..b735275f 100644 --- a/src/ui/setup_mainwindow.py +++ b/src/ui/setup_mainwindow.py @@ -69,6 +69,7 @@ def __init__(self): DP_CONTROLLER.set_display_settings(self) DP_CONTROLLER.set_tab_width(self) ICONS.set_mainwindow_icons(self) + DP_CONTROLLER.say_welcome_message() self.update_check() self._connection_check() self._deprecation_check()