├── .github └── workflows │ └── multiarch-image.yaml ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── asset ├── MQTT.MD ├── docker_install.MD ├── installingbeta.md ├── logo.png ├── mqtt_addon.png ├── mqtt_conf.png ├── mqtt_discovery.png └── mqtt_integration.png ├── config.yaml ├── icon.png ├── license.md ├── repository.json └── rootfs ├── etc └── services.d │ └── scheduler │ ├── finish │ └── run └── simplescheduler ├── main.py ├── options.default ├── scheduler.sh ├── simpleschedulerconf.py └── templates ├── config.html ├── edit.html ├── favicon.ico ├── index.html ├── new.html └── style.css /.github/workflows/multiarch-image.yaml: -------------------------------------------------------------------------------- 1 | name: Build Docker Multiarch Image 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | REGISTRY: ghcr.io 8 | IMAGE_NAME: arthurdent75/simplescheduler 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Log in to the Container registry 19 | uses: docker/login-action@v3 20 | with: 21 | registry: ghcr.io 22 | username: ${{ github.actor }} 23 | password: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Extract Info 26 | id: extract_info 27 | shell: bash 28 | run: | 29 | VERSION=$(yq e '.version' config.yaml) 30 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 31 | 32 | - name: Docker meta 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | 38 | - name: Set up QEMU 39 | uses: docker/setup-qemu-action@v3 40 | 41 | - name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v3 43 | 44 | - name: Build and push container image 45 | uses: docker/build-push-action@v6 46 | id: docker_build 47 | with: 48 | context: . 49 | platforms: linux/amd64,linux/arm64,linux/arm/v7 50 | push: true 51 | tags: | 52 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 53 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.extract_info.outputs.version }} 54 | labels: | 55 | org.opencontainers.image.title=SimpleScheduler Addon 56 | org.opencontainers.image.description="A Home Assistant AddOn to schedule entities on/off" 57 | org.opencontainers.image.vendor=SimpleScheduler Addon 58 | org.opencontainers.image.authors=arthurdent75 59 | org.opencontainers.image.url=https://github.com/arthurdent75/simplescheduler 60 | org.opencontainers.image.source=https://github.com/arthurdent75/simplescheduler 61 | org.opencontainers.image.version=${{ steps.extract_info.outputs.version }} 62 | io.hass.name=SimpleScheduler Addon 63 | io.hass.url=https://github.com/arthurdent75/simplescheduler 64 | io.hass.version=${{ steps.extract_info.outputs.version }} 65 | io.hass.type=addon 66 | io.hass.arch=armv7|aarch64|amd64 67 | annotations: | 68 | org.opencontainers.image.title=SimpleScheduler Addon 69 | org.opencontainers.image.description="A Home Assistant AddOn to schedule entities on/off" 70 | org.opencontainers.image.vendor=SimpleScheduler Addon 71 | org.opencontainers.image.authors=arthurdent75 72 | org.opencontainers.image.version=${{ steps.extract_info.outputs.version }} 73 | org.opencontainers.image.url=https://github.com/arthurdent75/simplescheduler 74 | 75 | 76 | - name: Image digest 77 | run: echo ${{ steps.docker_build.outputs.digest }} 78 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | **Version 3.2.1** 2 | - Fixes "Restarting MQTT thread..." (#199) 3 | 4 | **Version 3.2** 5 | - Fixes crashes in scheduler on timeout (#193) 6 | - Add support for drag on mobile (#192) 7 | - Drag only by handle icon 8 | - Added support for double click on mobile 9 | - Optimized scheduler routine 10 | - Fixes Scheduler thread crashes when using unsupported times separator (#198) 11 | - Manage wrong id filename 12 | 13 | **Version 3.1** 14 | - New feature: "excluded entities" in config (#149) 15 | - New feature: added group recap when closed (#187) 16 | - New feature: autoswitch to dark theme (#190) 17 | - Resizable sidebar 18 | - Improved error log 19 | - Many bugfixes in frontend (including: #188,#189) 20 | 21 | **Version 3.0** 22 | - New feature: arrange scheduler in group 23 | - New feature: call script with parameters 24 | - New feature: add support for INPUT_BUTTON 25 | - Update addon base image with multi-architecture support 26 | - Faster download: reduced image size by 85% 27 | - Threading architecture 28 | - Update paho-mqtt version 29 | - No need to restart add-on to enable/disable MQTT 30 | - A lot of bugfix 31 | 32 | **Version 2.6** 33 | - New feature: add support for BUTTON 34 | - New feature: double click on row to enable/disable scheduler (#165) 35 | - New feature: notification on failure (#134) 36 | - Improvement: entity dropboxes are now searchable 37 | - Improvement: Reduced backup size (#139,#137,#85) 38 | - Fix "returned a non-zero code" during install on some devices (#158) 39 | - Fix "Crash on dot instead of colon" (#177) 40 | - Fix vacuum start and stop 41 | 42 | **Version 2.5** 43 | - New feature: add template conditions 44 | - New feature: support valve (#146) 45 | - Fix crash due to new mqtt library (#151) 46 | - Improved documentation (including #152) 47 | - Fix few bugs in frontend 48 | 49 | **Version 2.2.1** 50 | - Fix "Simple Scheduler no longer switching on lamps with brightness" (#142) 51 | 52 | **Version 2.2** 53 | - New feature: add parameters for RGB/CT lights (#138) 54 | - New feature: support humidifiers (#135) 55 | 56 | **Version 2.11** 57 | - Fix "Allow non admin users to view panel" (#74) 58 | 59 | **Version 2.1** 60 | - Fix "Corrupted JSON files crash the addon" (#123) 61 | - Fix "Setting hours to 24 crash the addon" (#124) 62 | 63 | **Version 2.0.50 (beta)** 64 | - Fix MQTT Switches unavailable on restart (#111) 65 | - Fix error if entity is null (#110) 66 | 67 | **Version 2.0.48 (beta)** 68 | - create json folder if missing 69 | - enable flask logs 70 | 71 | **Version 2.0 (beta)** 72 | - Rewritten from scratch in Python 73 | - Complete reengineering of the docker structure 74 | - New feature: Configuration moved to frontend (you need to set option again!) 75 | - New feature: Recurring type scheduler (from - to - every) 76 | - New feature: Added "Do not retry" flag 77 | - New feature: Added "Clone" button 78 | - New MQTT Engine 79 | - Auto respawn processes (frontend and scheduler) in case of crash 80 | - Full UTF-8 support in scheduler name (include regional, mathematical, symbols and emoji) 81 | - Log moved to frontend 82 | - Log improvement (more clear and more detalied) 83 | - Improvement of "Max Retry" option behavior 84 | - Avoid queuing of some domains (script, scene, ecc) 85 | - Massive bugfix 86 | 87 | **Version 0.64** 88 | - Improvement: Set absolute value for light (#76) 89 | - Improvement: MQTT Switches become unavailable if addon is not running 90 | - Fix MQTT Switches duplicate with an ending 2 (#80) 91 | 92 | **Version 0.62** 93 | - Timezone issue (#75) 94 | - Added some fixes to allow installation on not-supervised env (thanks to @micw) 95 | 96 | **Version 0.61** 97 | - Allow non admin users to view panel (#74) 98 | - Fix "Missing translations" (#69) 99 | 100 | **Version 0.60** 101 | - Switch Docker image to HA Debian base 102 | - Complete reengineering of the docker structure 103 | - Fix "returned a non-zero code" issue in supervised installation (#65) 104 | - Added support for VACUUM 105 | 106 | **Version 0.50** 107 | - New feature: Retry unavailable entities 108 | - New feature: enable/disable schedulers in frontend (through MQTT) 109 | - Improvement: Set fan percent speed 110 | - Fixed bug in sorting (#61) 111 | - Log improvement 112 | - Several bugfixes 113 | 114 | **Version 0.40** 115 | - New feature: week-based scheduler 116 | - Improvement: added dark theme 117 | - Improvement: added Fan 118 | - Updated UI 119 | - Several bugfixes 120 | 121 | **Version 0.35** 122 | - Fix Temperature Only on multiple climate issue (#53) 123 | 124 | **Version 0.34** 125 | - Improvement: Change climate temperature without turning it on (#51) 126 | - Update material icons to latest version 127 | 128 | **Version 0.33** 129 | - Improvement: manage commas as separator in time list (#46) 130 | - Improvement: Added Cover (with position) (#49) 131 | 132 | **Version 0.32** 133 | - Fix "too many time" visualization issue (#48) 134 | 135 | **Version 0.31** 136 | - Fix single quote issue (#44) 137 | - Javascript optimization 138 | 139 | **Version 0.30** 140 | - Can set brightness to Lights 141 | - Can set temperature in Climates 142 | - Can add positive/negative offset to sunset/sunrise 143 | - Embed style and script to avoid cache issues 144 | - Bugfix 145 | 146 | **Version 0.22** 147 | - Fixed a bug in scheduler 148 | 149 | **Version 0.21** 150 | - Fixed visualization of UTF-8 chars (issue #11) 151 | 152 | **Version 0.20** 153 | - Add Name to scheduling 154 | - Can add more entities in one scheduler 155 | - Can add multiple time in one scheduler 156 | - Can drag rows to sort them 157 | - Changed edit from row to sidebar 158 | - Little graphic rewiew 159 | - Code improvements 160 | 161 | **Version 0.16** 162 | - Fix option.json issue 163 | - Removed table sorting 164 | 165 | **Version 0.15** 166 | - Fix Timezone Issue 167 | 168 | **Version 0.14** 169 | - Fix the “permission denied” issue 170 | - Sortable Columns 171 | - Added status bar with scheduler engine status 172 | - Added “debug option” to see all the error messages 173 | - various bugfixes/improvements 174 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/hassio-addons/base-python:16.1.0 2 | 3 | RUN pip3 install Flask requests pytz psutil paho-mqtt 4 | 5 | COPY rootfs / 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleScheduler 2 | 3 | A Home Assistant add-on to schedule switches, lights, and other entities on a weekly base in a visual way without coding.\ 4 | You can keep all the schedules in one place and add/change them in a few clicks, even on your mobile app. 5 | 6 | ![SimpleScheduler](https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/master/asset/logo.png) 7 | 8 | ### Installation 9 | Add the repository and then the addon by clicking on the badges:\ 10 | [](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Farthurdent75%2FSimpleScheduler) \ 11 | [](https://my.home-assistant.io/redirect/supervisor_addon/?addon=00185a40_simplescheduler) \ 12 | If something goes wrong, you can install it manually.\ 13 | You can add the URL of this page to your "add-on store" as a new repository:\ 14 | *Settings > Add-ons > ADD-ON STORE button in bottom right corner > three dots in top right corner > Repositories*\ 15 | Click *Check for updates* and you will find the add-on "Simple Scheduler" listed. 16 | 17 | If you are not using a supervised installation, you can run the addon as a standalone Docker. 18 | Take a look here: [docker_install.MD](https://github.com/arthurdent75/SimpleScheduler/blob/master/asset/docker_install.MD "docker_install.MD") 19 | 20 | ### Type of scheduler 21 | 22 | There are three kinds of schedulers: 23 | - **Daily**: you can set an ON/OFF time (or a list of them) and then you choose on which weekdays you want to enable them. 24 | - **Weekly**: you can set a different ON/OFF time (or list of them) for every single day of the week. 25 | - **Recurring**: you can set a recurring ON/OFF (FROM hh:mm TO hh:mm EVERY n MINUTES) and choose on which weekdays you want to enable them. 26 | 27 | ### How to use it 28 | The add-on is very easy and intuitive (or, at least, that's the goal)\ 29 | Once installed, open the GUI, click on the round plus button in the top left, and choose your schedule type.\ 30 | Choose one or more entities from the dropdown, fill in the ON time (in 24-hour format with leading zero, as suggested), and select the weekdays. Do the same for the OFF time and click "save". 31 | 32 | - You can set **multiple times** in the same scheduler: just enter them in the ON/OFF field **separated by spaces**. 33 | - It's not mandatory to add both ON and OFF times. You can leave one of them empty if you don't need it. For example, you want to turn off a light every day at 22:00, but you don't need to turn it on. 34 | - You can use the words **sunrise** and **sunset** instead of *hh:mm*; if needed, you can add an offset (in minutes). Sunset and Sunrise times are recalculated every day at midnight and are reported in the status bar. Some examples: **sunrise+30** is executed 30 minutes after sunrise; **sunset-60** is executed 1 hour before sunset. 35 | - You can **drag the rows to sort them**, so you can keep them organized as you like! 36 | - You can also choose to **disable a schedule**: the schedule will stay there, but it will not be executed until you enable it back. You can double-click on a row to quickly enable/disable the scheduler. 37 | - You can **organize schedulers in groups**, that can be expanded and collapsed as you like. You can open, close, drag, disable, rename, and delete them. Add a schedule to a group by dragging it over and remove it by dragging it out. If you delete a group, the schedulers inside the group will not be deleted. 38 | 39 | ## Features 40 | 41 | | **Entity** | **ON action** | **OFF action** | **Extra features**** | 42 | |:---|:---:|:---:|:-----| 43 | | light | ON | OFF | **Brightness (%)**
*e.g: set brightness to 30%*
`hh:mm>B30`

**Brightness (absolute)**
*e.g: set absolute brightness to 200*
`hh:mm>BA200`

**Color (hex)**
*e.g: set brightness to 75% and color to orange*
`hh:mm>B75\|FFA500`

**Color temp. (°K)**
*e.g: set brightness to 20% and color temperature to 4700°K*
`hh:mm>B20\|K4700`
| 44 | | cover | OPEN | CLOSE | **Percent (%)**
*e.g: open cover to 50%*
`hh:mm>P50` | 45 | | fan | ON | OFF | **Percent (%)**
*e.g: Turn on fan at 25%*
`hh:mm>F25` | 46 | | valve | OPEN | CLOSE | **Position (%)**
*e.g: Open valve at 50%*
`hh:mm>P50` | 47 | | climate | ON | OFF | **Temperature**
*e.g: turn on the climate and set temperature to 22.5°*
`hh:mm>T22.5`

**Temperature Only**
*e.g: just set temperature to 20.7°*
`hh:mm>TO20.7` | 48 | | humidifier | ON | OFF | **Humidity (°)**
*e.g: turn on the (de)humidifier and set the humidity to 55%*
`hh:mm>H55` | 49 | | switch | ON | OFF | | 50 | | button | PRESS | - | | 51 | | input_button | PRESS | - | | 52 | | vacuum | START | HOME | | 53 | | media_player | ON | OFF | | 54 | | script | RUN | STOP | **Parameters**
*send parameters (JSON) to the script*
`hh:mm>J{"field 1":"value 1",...,"field n":"value n",}` | 55 | | scene | ON | - | | 56 | | camera | ON | OFF | | 57 | | automation | ENABLE | DISABLE | | 58 | | input_boolean | ON | OFF | | 59 | 60 | 61 | ***Extra features (obviously) work only in the "TURN ON" section !* 62 | 63 | ### Conditions 64 | For each scheduler, you can add a condition that will be checked. 65 | If the condition is 'true' the action will be performed and (obviously) it won't be executed if the condition is 'false'. 66 | The condition is a template expression you can add to the "template" field. 67 | If the field is empty, no check will be performed and the action will always be executed. \ 68 | The template expression **must return a boolean** ('True' or 'False'). Be sure to "convert" switches, lights, and any other entity states to boolean. \ 69 | It is strongly advised to set a default with `replace("unavailable", "true")` or `replace("unavailable", "false")` to avoid errors in case the entity becomes unavailable. \ 70 | A few examples: 71 | ``` 72 | {{ states('switch.my_switch') | replace('unavailable', 'false') | bool }} 73 | {{ not states('light.my_light') | replace('unavailable', 'false') | bool }} 74 | {{ states('sensor.room_temperature') | replace('unavailable', '22') | float > 23.5 }} 75 | {{ is_state('person.my_kid', 'not_home') | replace('unavailable', 'true') | bool }} 76 | {{ states('sensor.room_temperature') | replace('unavailable', '22') | float > 23.5 77 | and is_state('sun.sun', 'above_horizon') | replace('unavailable', 'true') | bool }} 78 | ``` 79 | If the template returns '*on*', '*open*', '*home*', '*armed*', '*1*', and so on, it will all be treated as 'False'. \ 80 | If the template expression has syntax errors it will be considered 'false', and it will be reported in the addon log.\ 81 | Use the template render utility in Developer Tools to test the condition before putting it into the scheduler. 82 | 83 | ### Failure notification 84 | If a schedule is not able to execute the action, it can send you a notification, using one of the notifiers available in your setup. You can activate it in the addon configuration by selecting a notifier from the **notify on error** dropdown, otherwise you must select the entry "*disabled*". Choose the native notifier "*persistent_notification*" to receive the message in the frontend. 85 | This addon does not allow the addition or configuration of a notifier. This operation must be performed in Home Assistant. More info here: https://www.home-assistant.io/integrations/notify/ 86 | 87 | ### Frontend switch to enable/disable (with MQTT) 88 | If you want to enable/disable schedulers in the front end and/or automation, you can achieve that through MQTT. 89 | This feature is disabled by default because it requires a working MQTT server (broker) and Home Assistant MQTT integration. 90 | Take a look at the [MQTT.MD](https://github.com/arthurdent75/SimpleScheduler/blob/master/asset/MQTT.MD "MQTT.MD") file to know more. 91 | 92 | ### Retry on unavailable 93 | By default, SimpleScheduler will retry 3 times if an entity is unavailable. The first retry attempt happens after 5 seconds, then every minute. You can change the number of retries in the addon options. The valid range is 0 to 5. 94 | 95 | ### Hidden scheduler details 96 | When you have a lot of schedulers the view can become messy. As a default, all the scheduler details are hidden, so you can have a clear look. 97 | You can toggle the visibility with the *eye* icon near the scheduler name. 98 | If you prefer to have the entire schedule always visible, you can easily achieve that by enabling **details_uncovered** in the addon configuration 99 | 100 | ### Dark theme 101 | The addon switches to dark theme accordingly to your setup 102 | 103 | ### Translation 104 | The default text language is English. There are very few words. 105 | If you want to translate them, you just need to take a look at the configuration section of the addon. 106 | Rewrite the words you would like to use in your language and restart the add-on. 107 | For the weekdays, as you can easily understand, only the first two characters are used. 108 | 109 | ### Two words about the stored data 110 | Every schedule (or row, if you prefer) is a JSON file stored in the `/share/simplescheduler` folder under the SAMBA share. 111 | This way the data can "survive" an addon upgrade or reinstallation. 112 | You can easily backup and restore them in case of failure. In the same way, you can (accidentally?) delete them. So be aware of that. 113 | 114 | ### Log 115 | The log file is stored in the same folder where the JSON files are stored `/share/simplescheduler` 116 | You can delete it if it becomes too large, but be sure to stop the addon first. 117 | You can enable a verbose log by checking the box **debug mode** in the addon configuration. 118 | When enabled, the log file can easily become very large, so be sure to keep the debug mode on only the required time. 119 | 120 | ### Last but not least 121 | If you want to convince me to stay up at night to work on this, just buy me a beer 🍺 \ 122 | You may say that regular people need coffee to do that. Well, I'm not a regular person. 123 | -------------------------------------------------------------------------------- /asset/MQTT.MD: -------------------------------------------------------------------------------- 1 | # MQTT Configuration 2 | To enble/disable schedulers in the frontend and/or in an automation, you can use this feature. This allow to create dynamic switches entities in Home Assistant for each scheduler. 3 | The switch is in this form: 4 | `switch.simplescheduler_id_scheduler_name` 5 | Switch is created within a minute of the creation of a new scheduler, and removed as you delete a scheduler. 6 | To use the feature you need three things: 7 | 1. a MQTT server (also named *broker*) 8 | 2. enable the MQTT integration in Home Assistant 9 | 3. activate the feature in Simplescheduler 10 | 11 | It's less complicated than it seems! 12 | 13 | ## If you already have MQTT 14 | Set up Simplescheduler to point to your MQTT broker and set *Enabled* to *true* 15 | 16 | ![](https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/master/asset/mqtt_conf.png) 17 | 18 | If auth is not used, leave the *username* and *password* fields empty. 19 | You also need the MQTT Integration in your Home Assistant with discovery enabled. If you use MQTT you should already have it but if you don't, take a look at step 2 in the next section. 20 | 21 | ## If you don't have MQTT configured 22 | You have to follow three simple steps: 23 | 24 | #### 1. Install the addon "Mosquitto broker" 25 | Install the addon "Mosquitto broker", start it and enable "Start on boot". 26 | No configuration needed. 27 | 28 | ![](https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/master/asset/mqtt_addon.png) 29 | #### 2. Install the MQTT integration 30 | In ***Configuration > Device & Services*** add the MQTT integration. 31 | If you succesfully complete the prevous task, Home Assistant should automatically discover the integration and notify you to add it. 32 | 33 | ![](https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/master/asset/mqtt_integration.png) 34 | 35 | Click on CONFIGURE and leave all the default, but be sure to enable auto discovery 36 | 37 | ![](https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/master/asset/mqtt_discovery.png) 38 | 39 | 40 | 41 | 42 | #### 3. Configure Simplescheduler 43 | Simplescheduler is already pre-configured to use the Mosquitto Addon, so leave the *server* and the *port* parameter as default. 44 | Set *enabled* to *true* and add the username and the password that you use to login in Home Assistant. 45 | 46 | ![](https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/master/asset/mqtt_conf.png) 47 | 48 | After that, restart the addon. 49 | 50 | If you don't want to write your main HA credentials in the add-on for any reason, you can create a new dedicated user and use those credentials. 51 | 52 | -------------------------------------------------------------------------------- /asset/docker_install.MD: -------------------------------------------------------------------------------- 1 | # Run this addon as a standalone docker 2 | 3 | If you are reading this tutorial, it's because you are familiar with Docker. Otherwise, it's not a good idea. 4 | 5 | ## Create a token to access Home Assistant 6 | 7 | Log in to Home Assistant, go to your user profile, create a *long-lived access token*, and copy it. 8 | 9 | ## Create a folder to store persistent data 10 | 11 | You have to create a folder where the addon will save the data. You need to specify that path in the docker run command 12 | 13 | ## Run the docker image 14 | 15 | Run: 16 | 17 | ``` 18 | docker run -d \ 19 | -e SUPERVISOR_TOKEN=previously-created-token \ 20 | -e HASSIO_URL=http://your-hass-url:port/api \ 21 | -v /path/to/persistent/data:/share/simplescheduler \ 22 | -p 8099:8099 \ 23 | ghcr.io/arthurdent75/simplescheduler 24 | ``` 25 | 26 | You can access the scheduler now at `http://localhost:8099`. 27 | 28 | 29 | -------------------------------------------------------------------------------- /asset/installingbeta.md: -------------------------------------------------------------------------------- 1 | # Install the beta version 2 | 3 | *First of all, thank you for helping me test the beta!* 4 | 5 | Add the beta repository 6 | 7 | https://github.com/arthurdent75/SimpleScheduler-BETA 8 | 9 | or click on the link below: 10 | 11 | [](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Farthurdent75%2FSimpleScheduler-BETA) 12 | 13 | You don't need to remove the official repository 14 | 15 | ![image](https://github.com/user-attachments/assets/4911b40d-886b-4b29-a74c-3ed7eafce675) 16 | 17 | After that, uninstall the **Simple Scheduler** addon... 18 | 19 | ![image](https://github.com/user-attachments/assets/149e26d4-09b0-4e48-a214-b316bf3e9e81) 20 | 21 | ...and install the **Simple Scheduler (beta)** addon 22 | 23 | ![image](https://github.com/user-attachments/assets/be55d5ad-71b3-4463-bd74-00c7dad42197) 24 | 25 | All the settings and the schedulers will be kept, but please **execute a backup for safety**. 26 | -------------------------------------------------------------------------------- /asset/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/7114f12cf2dce24e30b92c015bc271d09ff24b5b/asset/logo.png -------------------------------------------------------------------------------- /asset/mqtt_addon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/7114f12cf2dce24e30b92c015bc271d09ff24b5b/asset/mqtt_addon.png -------------------------------------------------------------------------------- /asset/mqtt_conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/7114f12cf2dce24e30b92c015bc271d09ff24b5b/asset/mqtt_conf.png -------------------------------------------------------------------------------- /asset/mqtt_discovery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/7114f12cf2dce24e30b92c015bc271d09ff24b5b/asset/mqtt_discovery.png -------------------------------------------------------------------------------- /asset/mqtt_integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/7114f12cf2dce24e30b92c015bc271d09ff24b5b/asset/mqtt_integration.png -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.2.1" 3 | name: Simple Scheduler 4 | description: Simple timer on weekly base 5 | slug: simplescheduler 6 | url: https://github.com/arthurdent75/SimpleScheduler 7 | codenotary: notary@home-assistant.io 8 | arch: 9 | - aarch64 10 | - amd64 11 | - armv7 12 | image: ghcr.io/arthurdent75/simplescheduler 13 | init: false 14 | homeassistant_api: true 15 | ingress: true 16 | panel_admin: false 17 | panel_title: Scheduler 18 | panel_icon: mdi:calendar-clock 19 | map: 20 | - share:rw 21 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/7114f12cf2dce24e30b92c015bc271d09ff24b5b/icon.png -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # SimpleScheduler License 2 | 3 | Copyright ©2022 4 | 5 | ## 1. Grant of Use 6 | SimpleScheduler is a free Home Assistant addon developed by **arthurdent75**. You are permitted to install, use, and modify the source code **for personal, non-commercial purposes only**. 7 | 8 | ## 2. Restrictions 9 | - You may **not** distribute, share, sublicense, or sell modified versions of this addon, either in source code or compiled form. 10 | - You may **not** use this addon for commercial purposes without prior written permission from the author. 11 | - You may **not** use routines, functions, or parts of this code to develop another Home Assistant addon or any similar software. 12 | - Any modifications must retain the original author's attribution, including name and repository link. 13 | 14 | ## 3. Attribution Requirement 15 | If you modify SimpleScheduler for personal use, you **must** include the following attribution in the documentation and code comments: 16 | > "Based on SimpleScheduler Addon for Home Assistant by arthurdent75 ([https://github.com/arthurdent75/SimpleScheduler](https://github.com/arthurdent75/SimpleScheduler))" 17 | 18 | ## 4. Disclaimer of Warranty 19 | THIS SOFTWARE IS PROVIDED "AS IS," WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR ITS USE. 20 | 21 | ## 5. Copyright and License Violation 22 | Any unauthorized use of this software, including but not limited to violating the restrictions stated in this license, **constitutes a breach of copyright law and an infringement of this license**. The author reserves the right to take legal action against any individual or entity that does not comply with these terms. 23 | 24 | 25 | -------------------------------------------------------------------------------- /repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SimpleScheduler Repository", 3 | "url": "https://github.com/arthurdent75/SimpleScheduler", 4 | "maintainer": "Alessandro Argentiero " 5 | } 6 | -------------------------------------------------------------------------------- /rootfs/etc/services.d/scheduler/finish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | # ============================================================================== 3 | # Home Assistant Community Add-on: Scheduler 4 | # ============================================================================== 5 | if [[ "${1}" -ne 0 ]] && [[ "${1}" -ne 256 ]]; then 6 | bashio::log.warning "Scheduler process crashed..." 7 | # /run/s6/basedir/bin/halt 8 | fi 9 | 10 | bashio::log.info "Restarting..." 11 | -------------------------------------------------------------------------------- /rootfs/etc/services.d/scheduler/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | # ============================================================================== 3 | # 4 | # Home Assistant Add-on: SimpleScheduler 5 | # 6 | # ============================================================================== 7 | 8 | bashio::log.info "Starting service.d [Scheduler]" 9 | 10 | exec /simplescheduler/scheduler.sh 11 | 12 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, redirect, make_response 2 | import paho.mqtt.client as mqtt 3 | import flask.cli 4 | import logging 5 | import glob 6 | import os 7 | import json 8 | from datetime import datetime, timedelta 9 | import pytz 10 | import requests 11 | import time 12 | import uuid 13 | import threading 14 | import re 15 | import base64 16 | import fnmatch 17 | 18 | import simpleschedulerconf 19 | 20 | valid_domains = ["light", "scene", "switch", "button", "script", "camera", "climate", "cover", "vacuum", "fan", "humidifier", 21 | "valve","automation", "input_boolean", "input_button","media_player"] 22 | lwt_topic = "homeassistant/switch/simplescheduler/availability" 23 | no_notification_placeholder = " - disabled - " 24 | sun_data = "" 25 | schedulers_list = [] 26 | options: dict = {} 27 | weekday = [] 28 | has_changed: bool = False 29 | mqttclient = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="SimpleScheduler", clean_session=False) 30 | mqtt_enabled = False 31 | ha_timezone = "utc" 32 | request_timeout = 5 # seconds 33 | domain_list = [] 34 | 35 | 36 | app = Flask(__name__) 37 | 38 | 39 | @app.route("/") 40 | @app.route("/main") 41 | def webserver_home(): 42 | return render_template('index.html', 43 | data=load_json_schedulers(), 44 | o=get_options(), 45 | css=get_css(), 46 | switchlist=get_switch_html_select_options(), 47 | friendlynames=get_switch_friendly_names(), 48 | sort=get_sort_list(), 49 | weekday=weekday, 50 | statusbarinfo=get_statusbar_info(), 51 | groups=get_groups() 52 | ) 53 | 54 | 55 | @app.route("/new") 56 | def webserver_new(): 57 | return render_template('new.html') 58 | 59 | 60 | @app.route('/delete', methods=['GET']) 61 | def webserver_delete(): 62 | args = request.args 63 | sid = args.get('id') 64 | file = simpleschedulerconf.json_folder + sid + '.json' 65 | if os.path.exists(file): 66 | os.remove(file) 67 | if options['MQTT']['enabled']: 68 | mqttclient.publish('homeassistant/switch/simplescheduler/' + sid + '/config', "", qos=0, retain=True) 69 | return redirect("main") 70 | 71 | 72 | @app.route('/edit', methods=['GET']) 73 | def webserver_edit(): 74 | is_new = False 75 | args = request.args 76 | sid = args.get('id') 77 | stype: str = args.get('type') 78 | file = simpleschedulerconf.json_folder + sid + '.json' 79 | if sid != "0": 80 | with open(file, "r") as read_file: 81 | param = json.load(read_file) 82 | else: 83 | param = json.loads(get_json_template(stype)) 84 | param['id'] = uuid.uuid4().hex 85 | is_new = True 86 | 87 | return render_template('edit.html', 88 | p=param, 89 | o=get_options(), 90 | weekday=weekday, 91 | switchlist=get_switch_html_select_options(), 92 | is_new=is_new 93 | ) 94 | 95 | 96 | @app.route('/update_json', methods=['GET']) 97 | def webserver_update_json(): 98 | global mqtt_enabled 99 | args = request.args 100 | sid = args.get('id') 101 | f:str = args.get('f') 102 | v = args.get('v') 103 | if f=="enabled": v=int(v) 104 | result=update_json_file(sid,f,v) 105 | r = '1' if result else '0' 106 | if options['MQTT']['enabled']: 107 | mqtt_enabled = True 108 | mqtt_send_config(mqttclient) 109 | return make_response(r, 200) 110 | 111 | 112 | @app.route('/config', methods=['GET']) 113 | def webserver_config(): 114 | return render_template('config.html', 115 | o=get_options(), 116 | d=valid_domains, 117 | notifiers=get_notifiers() 118 | ) 119 | 120 | 121 | @app.route('/saveconfig', methods=['GET']) 122 | def webserver_saveconfig(): 123 | jsondata = {} 124 | translations = {} 125 | components = {} 126 | mqttconf = {} 127 | content = request.args 128 | for item in content: 129 | if content[item] == '0' or content[item] == '1': 130 | value = int(content[item]) 131 | else: 132 | value = content[item] 133 | if "." in item: 134 | p = item.split(".") 135 | cat = p[0].lower() 136 | subcat = p[1] 137 | if cat == "translations": translations[subcat] = value 138 | if cat == "components": components[subcat] = value 139 | if cat == "mqtt": mqttconf[subcat] = value 140 | else: 141 | if item=="excluded_entities": 142 | value = re.sub(r"[^\n\ra-z0-9_*.]", "", value.lower()) 143 | jsondata[item] = value 144 | 145 | jsondata["translations"] = translations 146 | jsondata["components"] = components 147 | jsondata["MQTT"] = mqttconf 148 | 149 | option_file_path = os.path.join(simpleschedulerconf.json_folder, "options.dat") 150 | with open(option_file_path, 'w') as option_file: 151 | json.dump(jsondata, option_file) # type: ignore 152 | 153 | init() 154 | 155 | return redirect("main") 156 | 157 | 158 | @app.route('/clone', methods=['GET']) 159 | def webserver_clone(): 160 | args = request.args 161 | sid = args.get('id') 162 | file = simpleschedulerconf.json_folder + sid + '.json' 163 | if os.path.exists(file) and sid != "0": 164 | with open(file, "r") as read_file: 165 | param = json.load(read_file) 166 | newsid = uuid.uuid4().hex 167 | newfile = simpleschedulerconf.json_folder + newsid + '.json' 168 | param['id'] = newsid 169 | param['name'] = param['name'] + " (2) " 170 | with open(newfile, 'w') as jsonFile: 171 | json.dump(param, jsonFile) # type: ignore 172 | return redirect("main") 173 | 174 | 175 | @app.route("/update", methods=['POST']) 176 | def webserver_update(): 177 | on_tod = off_tod = on_dow = off_dow = on_tod_false = off_tod_false = "" 178 | sid = request.form.get('id') 179 | enabled = request.form.get("enabled") 180 | dontretry = request.form.get("dontretry") 181 | template = request.form.get("template") 182 | name = request.form.get("name") 183 | entity_id = request.form.getlist('entity_id[]') 184 | sched_type:str = request.form.get('type',"") 185 | if sched_type != 'weekly': 186 | on_tod = request.form.get('on_tod' , "") 187 | on_tod_false = request.form.get('on_tod_false') 188 | off_tod = request.form.get('off_tod') 189 | off_tod_false = request.form.get('off_tod_false') 190 | on_dow = "" 191 | off_dow = "" 192 | for o in request.form.getlist('on_dow[]'): 193 | on_dow += o 194 | for o in request.form.getlist('off_dow[]'): 195 | off_dow += o 196 | 197 | data = json.loads(get_json_template(sched_type)) 198 | data['id'] = sid 199 | data['name'] = name if name else sid 200 | data['enabled'] = enabled if enabled else 0 201 | data['dontretry'] = dontretry if dontretry else 0 202 | data['template'] = template.replace('"',"'") if template else '' 203 | data['entity_id'] = entity_id 204 | if sched_type == 'weekly': 205 | data['weekly']['on_1'] = request.form.get('on_1') 206 | data['weekly']['on_2'] = request.form.get('on_2') 207 | data['weekly']['on_3'] = request.form.get('on_3') 208 | data['weekly']['on_4'] = request.form.get('on_4') 209 | data['weekly']['on_5'] = request.form.get('on_5') 210 | data['weekly']['on_6'] = request.form.get('on_6') 211 | data['weekly']['on_7'] = request.form.get('on_7') 212 | data['weekly']['off_1'] = request.form.get('off_1') 213 | data['weekly']['off_2'] = request.form.get('off_2') 214 | data['weekly']['off_3'] = request.form.get('off_3') 215 | data['weekly']['off_4'] = request.form.get('off_4') 216 | data['weekly']['off_5'] = request.form.get('off_5') 217 | data['weekly']['off_6'] = request.form.get('off_6') 218 | data['weekly']['off_7'] = request.form.get('off_7') 219 | elif sched_type == 'recurring': 220 | data['recurring']['on_start'] = request.form.get('on_start') 221 | data['recurring']['on_end'] = request.form.get('on_end') 222 | data['recurring']['on_interval'] = request.form.get('on_interval') 223 | data['recurring']['off_start'] = request.form.get('off_start') 224 | data['recurring']['off_end'] = request.form.get('off_end') 225 | data['recurring']['off_interval'] = request.form.get('off_interval') 226 | data['on_tod'] = on_tod 227 | data['off_tod'] = off_tod 228 | data['on_dow'] = on_dow 229 | data['off_dow'] = off_dow 230 | else: 231 | data['on_tod'] = on_tod 232 | data['off_tod'] = off_tod 233 | data['on_dow'] = on_dow 234 | data['off_dow'] = off_dow 235 | data['on_tod_false'] = on_tod_false 236 | data['off_tod_false'] = off_tod_false 237 | file = simpleschedulerconf.json_folder + sid + '.json' 238 | with open(file, 'w') as jsonFile: 239 | json.dump(data, jsonFile) # type: ignore 240 | if options['MQTT']['enabled']: 241 | mqtt_send_config(mqttclient) 242 | return redirect("main") 243 | 244 | 245 | @app.route("/sort", methods=['GET']) 246 | def webserver_sort(): 247 | data = request.args 248 | save_sort_list(data) 249 | return make_response("", 200) 250 | 251 | 252 | @app.route("/log", methods=['GET']) 253 | def webserver_log(): 254 | response = "" 255 | logfilepath = os.path.join(simpleschedulerconf.json_folder, "simplescheduler.log") 256 | with open(logfilepath, "r", encoding='utf-8') as logfile: 257 | response += logfile.read() 258 | return make_response(response, 200) 259 | 260 | 261 | @app.route("/dirty") 262 | def webserver_dirty(): 263 | global has_changed 264 | if has_changed: 265 | r = '1' 266 | has_changed = False 267 | else: 268 | r = '0' 269 | return make_response(r, 200) 270 | 271 | @app.route("/validatetemplate", methods=['GET']) 272 | def webserver_validatetemplate(): 273 | response = "" 274 | data = request.args 275 | t = data["t"] 276 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 277 | command_url = simpleschedulerconf.HASSIO_URL + "/template" 278 | post_data = '{"template":"' + t + '"}' 279 | try: 280 | r = requests.post(url=command_url, data=post_data, headers=headers, timeout=request_timeout) 281 | if r.content: 282 | response = r.content.decode().lower() 283 | if r.status_code != 200: 284 | if "message" in response: 285 | json_response = json.loads(r.content) 286 | response = json_response['message'] 287 | finally: 288 | return make_response(response, 200) 289 | 290 | @app.context_processor 291 | def utility_processor(): 292 | def format_event(value: str, showvalue: bool): 293 | if not value: return '' 294 | result: str = "" 295 | value = encode_braces_to_base32(value) 296 | events = value.upper().replace(',', ' ').replace(';', ' ').split(" ") 297 | 298 | for e in events: 299 | p = e.split('>') # separate time from extra commands 300 | t: str = p[0] 301 | extra = "" 302 | if len(p) > 1 and showvalue: # verify extra commands 303 | prefix = p[1][0] 304 | v: str = p[1][1:] 305 | if prefix == 'F': 306 | extra = '' + v + '%' 307 | if prefix == 'P': 308 | extra = '' + v + '%' 309 | if prefix == 'T': 310 | if v[0] == 'O': 311 | v = v[1:] 312 | extra = '' + v + '°' 313 | else: 314 | extra = '' + v + '°' 315 | if prefix == 'H': 316 | extra = '' + v + '%' 317 | if prefix == 'B': 318 | vv = v.split('|') 319 | brightness = vv[0] 320 | extrainfo = "" 321 | if len(vv) > 1: 322 | color = vv[1] 323 | colorValue = color[1:] 324 | if color[0] == 'K': 325 | extrainfo = ' ' + colorValue + '°K' 326 | else: 327 | extrainfo = '
' 328 | if brightness[0] == 'A': 329 | brightness = brightness[1:] 330 | extra = '' + brightness + extrainfo + '' 331 | else: 332 | extra = '' + brightness + '%' + extrainfo + '' 333 | if prefix == 'J': 334 | extra = ' ' 335 | if t: result += '' + t + extra + '' 336 | 337 | return result 338 | 339 | return dict(format_event=format_event) 340 | 341 | 342 | @app.context_processor 343 | def utility_processor(): 344 | def get_friendly_html_dow(value: str, is_on: bool): 345 | result: str = "
" 346 | if len(value) > 0: 347 | onOffClass = "dowHiglightG" if is_on else "dowHiglightR" 348 | for wd in range(1, 8): 349 | d = weekday[wd] 350 | dclass = "" 351 | if str(wd) in value: 352 | dclass = onOffClass 353 | result += '
' + d + '
' 354 | result += '
' 355 | return result 356 | 357 | return dict(get_friendly_html_dow=get_friendly_html_dow) 358 | 359 | 360 | def on_connect(client, userdata, flags, rc,properties): 361 | if rc == 0: 362 | printlog("STATUS: MQTT connected! ") 363 | client.publish(lwt_topic, payload="online", qos=0, retain=True) 364 | client.subscribe("homeassistant/switch/simplescheduler/#") 365 | else: 366 | printlog("ERROR: MQTT Error " + str(rc)) 367 | 368 | 369 | def on_message(client, userdata, msg): 370 | global has_changed 371 | payload = 0 372 | pieces = msg.topic.split("/") 373 | if len(pieces) > 4: 374 | if pieces[4] == "set": 375 | sid = pieces[3] 376 | printlog('MQTT: RCV ' + msg.topic + " --> " + msg.payload.decode()) 377 | if msg.payload.decode() == 'ON': 378 | payload = 1 379 | update_json_file(sid, 'enabled', payload) 380 | if options['MQTT']['enabled']: 381 | mqtt_publish_state(client, sid, payload, True) 382 | has_changed = "1" 383 | 384 | 385 | def get_statusbar_info(): 386 | r = { 387 | "sunrise": "N/A", 388 | "sunset": "N/A", 389 | "timezone": "N/A", 390 | "scheduler": "Not running", 391 | "mqtt": "Disabled" 392 | } 393 | tz = get_ha_timezone() 394 | sunrise, sunset = get_sun(tz) 395 | r['timezone'] = tz if tz else "N/A" 396 | r['sunrise'] = sunrise.strftime("%H:%M") if sunrise else "N/A" 397 | r['sunset'] = sunset.strftime("%H:%M") if sunset else "N/A" 398 | if mqtt_enabled and mqttclient is not None: 399 | r['mqtt'] = "Connected" if mqttclient.is_connected() else "Disconnected" 400 | return r 401 | 402 | 403 | def get_switch_list(domains): 404 | full_switch_list = [] 405 | url = simpleschedulerconf.HASSIO_URL + "/states" 406 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 407 | try: 408 | opt = get_options() 409 | exclusions = opt.get('excluded_entities', "").splitlines() 410 | r = requests.get(url=url, headers=headers, timeout=request_timeout) 411 | for block in r.json(): 412 | item = { 413 | "id": block["entity_id"], 414 | "state": block["state"], 415 | "friendly_name": "", 416 | "domain": "", 417 | } 418 | 419 | pieces = block["entity_id"].split(".") 420 | item["domain"] = pieces[0] 421 | 422 | attributes = block["attributes"] 423 | if "friendly_name" in attributes: 424 | item["friendly_name"] = attributes["friendly_name"] 425 | 426 | if item["domain"] in domains: 427 | if not any(fnmatch.fnmatch(block["entity_id"], pattern) for pattern in exclusions): 428 | full_switch_list.append(item) 429 | 430 | full_switch_list.sort(key=lambda x: x["id"], reverse=False) 431 | except Exception as e: 432 | printlog("ERROR: Unable to obtain entities info from Home Assistant",e) 433 | return full_switch_list 434 | 435 | 436 | def get_domains(): 437 | domain_list = [] 438 | url = simpleschedulerconf.HASSIO_URL + "/states" 439 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 440 | try: 441 | r = requests.get(url=url, headers=headers, timeout=request_timeout) 442 | for block in r.json(): 443 | 444 | pieces = block["entity_id"].split(".") 445 | if pieces[0] not in domain_list: 446 | domain_list.append(pieces[0]) 447 | 448 | domain_list.sort() 449 | except Exception as e: 450 | printlog("ERROR: Unable to obtain domain list from Home Assistant",e) 451 | return domain_list 452 | 453 | 454 | def get_switch_friendly_names(): 455 | friendly_names = {} 456 | url = simpleschedulerconf.HASSIO_URL + "/states" 457 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 458 | try: 459 | r = requests.get(url=url, headers=headers, timeout=request_timeout) 460 | for block in r.json(): 461 | key = block["entity_id"] 462 | value = key 463 | if "friendly_name" in block["attributes"]: 464 | value = block["attributes"]["friendly_name"] 465 | friendly_names[key] = value 466 | except Exception as e: 467 | printlog("ERROR: Unable to obtain entities names from Home Assistant",e) 468 | return friendly_names 469 | 470 | 471 | def update_json_file(object_id, field_name, field_value): 472 | file = simpleschedulerconf.json_folder + object_id + '.json' 473 | try: 474 | if os.path.exists(file): 475 | with open(file, "r") as jsonFile: 476 | data = json.load(jsonFile) 477 | data[field_name] = field_value 478 | with open(file, "w") as jsonFile: 479 | json.dump(data, jsonFile) # type: ignore 480 | except Exception as e: 481 | printlog("ERROR: Unable to update JSON file",e) 482 | return True 483 | 484 | 485 | def load_json_schedulers(): 486 | ss = [] 487 | os.chdir(simpleschedulerconf.json_folder) 488 | 489 | for file in glob.glob("*.json"): 490 | with open(file, "r") as read_file: 491 | try: 492 | data = json.load(read_file) 493 | ss.append(data) 494 | 495 | filename_no_ext = os.path.splitext(file)[0] 496 | json_id = str(data.get("id", "")).strip() 497 | 498 | if json_id and filename_no_ext != json_id: 499 | new_filename = f"{json_id}.json" 500 | if not os.path.exists(new_filename): 501 | printlog(f"WARNING: Renaming {file} to {new_filename}") 502 | os.rename(file, new_filename) 503 | else: 504 | printlog(f"WARNING: Cannot rename {file} to {new_filename}, file already exists!") 505 | except Exception as e: 506 | printlog("ERROR: scheduler file %s is corrupted" % file, e) 507 | 508 | return ss 509 | 510 | 511 | def mqtt_publish_state(client, object_id, pub_value, echo=False): 512 | payload = 'OFF' 513 | if pub_value: 514 | payload = 'ON' 515 | topic = 'homeassistant/switch/simplescheduler/' + object_id + '/state' 516 | client.publish(topic, payload, qos=0, retain=1) 517 | if echo: 518 | printlog('MQTT: PUB ' + topic + ' --> ' + payload) 519 | 520 | 521 | def mqtt_send_config(client): 522 | schedulers = load_json_schedulers() 523 | payload_template = '{"unique_id": "simplescheduler_###",' \ 524 | '"name": "SimpleScheduler: @@@" ,' \ 525 | '"icon":"mdi:calendar-clock" ,' \ 526 | '"cmd_t": "homeassistant/switch/simplescheduler/###/set",' \ 527 | '"stat_t": "homeassistant/switch/simplescheduler/###/state",' \ 528 | '"avty_t": "homeassistant/switch/simplescheduler/availability",' \ 529 | '"pl_avail":"online",' \ 530 | '"pl_not_avail":"offline"}' 531 | config_topic_template = 'homeassistant/switch/simplescheduler/###/config' 532 | for S in schedulers: 533 | if S and 'name' in S: 534 | topic = config_topic_template.replace('###', S['id']) 535 | payload = payload_template.replace('###', S['id']) 536 | payload = payload.replace('@@@', S['name']) 537 | # slug = slugify(S['name'], separator="_") 538 | # payload = payload.replace('&&&', slug) 539 | client.publish(topic, payload, qos=0, retain=1) 540 | # time.sleep(.1) 541 | mqtt_publish_state(client, S['id'], S['enabled']) 542 | # time.sleep(.1) 543 | 544 | 545 | def get_options(): 546 | path = os.path.join(simpleschedulerconf.json_folder, "options.dat") 547 | if not os.path.exists(path): 548 | path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "options.default") 549 | with open(path, "r", encoding='utf-8') as read_file: 550 | opt = json.load(read_file) 551 | return opt 552 | 553 | 554 | def get_css(): 555 | global options 556 | path_light = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates", "style.css") 557 | css = "" 558 | try: 559 | with open(path_light, "r", encoding='utf-8') as css_file: 560 | css += css_file.read() 561 | except Exception as e: 562 | printlog("ERROR: Something went wrong while loading CSS",e) 563 | return css 564 | 565 | 566 | def get_enabled_domains(): 567 | enabled_domains = [] 568 | opt = get_options() 569 | for d in opt['components']: 570 | if opt['components'][d]: 571 | enabled_domains.append(d) 572 | return enabled_domains 573 | 574 | 575 | def get_switch_html_select_options(): 576 | htmlstring: str = '' 577 | switch_list = get_switch_list(get_enabled_domains()) 578 | comp = "" 579 | for s in switch_list: 580 | c = s['id'].split('.') 581 | if comp != c[0]: 582 | if comp != "": 583 | htmlstring += '' 584 | comp = c[0] 585 | htmlstring += '' 586 | name = s['id'] if s['friendly_name'] == "" else s['friendly_name'] + ' (' + s['id'] + ')' 587 | htmlstring += '' 588 | htmlstring += '' 589 | htmlstring = htmlstring.replace(chr(39), "'") 590 | return htmlstring 591 | 592 | 593 | def get_json_template(t: str): 594 | t = t.lower() 595 | json_template: str = '' 596 | if t == 'w' or t == 'weekly': 597 | json_template = '{"id":"","name":"","enabled":"1","entity_id":[""],"weekly":{"on_1":"","on_2":"","on_3":"",' \ 598 | '"on_4":"","on_5":"","on_6":"","on_7":"","off_1":"","off_2":"","off_3":"","off_4":"",' \ 599 | '"off_5":"","off_6":"","off_7":""}} ' 600 | if t == 'd' or t == 'daily' or t is None: 601 | json_template = '{"id":"","name":"","enabled":"1","entity_id":[""],"on_tod":"","on_dow":"","off_tod":"",' \ 602 | '"off_dow":""} ' 603 | if t == 'r' or t == 'recurring': 604 | json_template = '{"id":"","name":"","enabled":"1","entity_id":[""],"recurring":{"on_start":"","on_end":"",' \ 605 | '"on_interval":"","off_start":"","off_end":"","off_interval":""},"on_tod":"","on_dow":"",' \ 606 | '"off_tod":"","off_dow":""} ' 607 | 608 | return json_template 609 | 610 | 611 | def get_sort_list(): 612 | sorting = {} 613 | sort_file_path = simpleschedulerconf.json_folder + "sort.dat" 614 | try: 615 | if os.path.exists(sort_file_path): 616 | with open(sort_file_path, "r") as sort_file: 617 | order = json.load(sort_file) 618 | i = 0 619 | for sid in order['id_order']: 620 | sorting[sid] = i 621 | i = i + 1 622 | except Exception as e: 623 | printlog("ERROR: corrupted sort file",e) 624 | return sorting 625 | 626 | def get_groups(): 627 | g = "" 628 | group_file_path = simpleschedulerconf.json_folder + "group.dat" 629 | try: 630 | if os.path.exists(group_file_path): 631 | with open(group_file_path, "r") as group_file: 632 | g = json.load(group_file) 633 | except Exception as e: 634 | printlog("ERROR: corrupted group file",e) 635 | return g 636 | 637 | def save_sort_list(data): 638 | idlist = [] 639 | json_groups="" 640 | for el in data: 641 | if el == "groups": 642 | base64_groups = data[el] 643 | json_groups = base64.b64decode(base64_groups).decode('utf-8') 644 | else: 645 | idlist.append(data[el]) 646 | jsonlist = json.loads('{"id_order":[]}') 647 | jsonlist['id_order'] = idlist 648 | with open(simpleschedulerconf.json_folder + "sort.dat", "w") as sort_file: 649 | json.dump(jsonlist, sort_file) # type: ignore 650 | if json_groups.count('{') == json_groups.count('}'): 651 | with open(simpleschedulerconf.json_folder + "group.dat", "w") as group_file: 652 | group_file.write(json_groups) 653 | else: 654 | printlog("ERROR: Malformed group list received") 655 | printlog(" "+json_groups) 656 | return True 657 | 658 | 659 | def get_events_in_html(): 660 | events_html = {"events_on": "events_on", "events_off": "events_off", "days_on": "days_on", "days_off": "days_off"} 661 | return events_html 662 | 663 | 664 | def get_entity_status(e, check): 665 | response = "" 666 | url = simpleschedulerconf.HASSIO_URL + "/states/" + e 667 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 668 | try: 669 | r = requests.get(url=url, headers=headers, timeout=request_timeout) 670 | result = r.json() 671 | response = str(result['state']).lower() 672 | if check: 673 | altered_response = response 674 | domain = e.lower().split(".") 675 | if domain[0] == 'cover' or domain[0] == 'valve': 676 | altered_response = 'on' if response == "open" else 'off' 677 | if domain[0] == 'climate' and response != 'off': 678 | altered_response = 'on' 679 | if domain[0] == 'vacuum': 680 | altered_response = 'on' if response == "cleaning" else 'off' 681 | 682 | response = altered_response 683 | except Exception as e: 684 | printlog("ERROR: Unable to obtain entity status from Home Assistant",e) 685 | return response 686 | 687 | 688 | def evaluate_template(t: str): 689 | opt = get_options() 690 | response: bool = False 691 | content = "" 692 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 693 | command_url = simpleschedulerconf.HASSIO_URL + "/template" 694 | post_data = '{"template":"' + t + '"}' 695 | try: 696 | if opt['debug']: printlog("DEBUG: Evaluating template: %s" % (t)) 697 | r = requests.post(url=command_url, data=post_data, headers=headers, timeout=request_timeout) 698 | if r.content: 699 | content = r.content.decode() 700 | if r.status_code == 400 and "unavailable" in content.lower(): 701 | return False 702 | if r.status_code != 200: 703 | printlog("ERROR: Error calling HA API " + str(r.status_code)) 704 | if "message" in content.lower(): 705 | json_response = json.loads(r.content) 706 | error= json_response['message'] 707 | printlog("ERROR: template error: %s" % (error)) 708 | else: 709 | if content.lower() == 'true': 710 | response = True 711 | 712 | except Exception as e: 713 | printlog("ERROR: Unable to call Home Assistant template API",e) 714 | return response 715 | 716 | 717 | def call_ha_api(command_url: str, post_data: str): 718 | opt = get_options() 719 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 720 | try: 721 | r = requests.post(url=command_url, data=post_data, headers=headers, timeout=request_timeout) 722 | command = command_url.replace(simpleschedulerconf.HASSIO_URL + "/services/", "") 723 | if opt['debug']: printlog("DEBUG: %s %s" % (command, post_data)) 724 | if r.status_code != 200: 725 | printlog("ERROR: Error calling HA API " + str(r.status_code)) 726 | except requests.exceptions.ReadTimeout: 727 | if not ("/script/" in command_url): 728 | printlog("ERROR: Unable to call Home Assistant service (timeout)") 729 | # NB: when you call a script, the response is sent when execution ends, 730 | # so scripts with delay throw timeout exception 731 | except Exception as e: 732 | printlog("ERROR: Unable to call Home Assistant service",e) 733 | return True 734 | 735 | 736 | def call_ha(eid_list, action, passedvalue, friendly_name): 737 | if not isinstance(eid_list, list): 738 | eid_list = {eid_list} 739 | for eid in eid_list: 740 | command = "Turning " + action.upper() 741 | extra = "" 742 | v = "" 743 | value = passedvalue.upper() 744 | domain = eid.split(".") 745 | command_url = simpleschedulerconf.HASSIO_URL + "/services/" + domain[0] + "/turn_" + action 746 | postdata = '{"entity_id":"%s"}' % eid 747 | 748 | if action == 'on': 749 | if domain[0] == "light" and value != "": 750 | pieces = value.split("|") 751 | part_one = pieces[0] 752 | if len(pieces) > 1: 753 | part_two = pieces[1] 754 | else: 755 | part_two = '' 756 | 757 | if part_one[0] == "A": 758 | v = int(part_one[1:]) 759 | extra = "to %d" % v 760 | elif part_one.isdigit(): 761 | v = int(int(part_one) * 2.55) 762 | extra = "to " + part_one + '%' 763 | postdata = '{"entity_id":"%s","brightness":"%d"}' % (eid, v) 764 | 765 | if part_two: 766 | if part_two[0] == "K": 767 | kelvin = int(part_two[1:]) 768 | postdata = '{"entity_id":"%s","brightness":"%d","color_temp_kelvin":"%d"}' % (eid, v, kelvin) 769 | else: 770 | HEX_color = part_two 771 | rgb = list(int(HEX_color[i:i + 2], 16) for i in (0, 2, 4)) 772 | postdata = '{"entity_id":"%s","brightness":"%d","rgb_color":%s}' % (eid, v, rgb) 773 | 774 | if domain[0] == "fan" and value != "": 775 | v = value 776 | extra = "to " + v + '%' 777 | postdata = '{"entity_id":"%s","percentage":"%s"}' % (eid, v) 778 | 779 | if domain[0] == "cover": 780 | if value != "": 781 | command_url = simpleschedulerconf.HASSIO_URL + "/services/cover/set_cover_position" 782 | postdata = '{"entity_id":"%s","position":"%s"}' % (eid, value) 783 | command = "Setting" 784 | extra = "position to " + value + '%' 785 | else: 786 | if action == "on": 787 | command_url = simpleschedulerconf.HASSIO_URL + "/services/cover/open_cover" 788 | command = "Opening" 789 | 790 | if domain[0] == "valve": 791 | if value != "": 792 | command_url = simpleschedulerconf.HASSIO_URL + "/services/valve/set_valve_position" 793 | postdata = '{"entity_id":"%s","position":"%s"}' % (eid, value) 794 | command = "Setting" 795 | extra = "position to " + value + '%' 796 | else: 797 | if action == "on": 798 | command_url = simpleschedulerconf.HASSIO_URL + "/services/valve/open_valve" 799 | command = "Opening" 800 | 801 | if domain[0] == "climate" and value != "": 802 | if value[0] == "O": 803 | v = value[1:] 804 | command_url = simpleschedulerconf.HASSIO_URL + "/services/climate/set_temperature" 805 | postdata = '{"entity_id":"%s","temperature":"%s"}' % (eid, v) 806 | command = "Setting" 807 | extra = "temperature to " + v + '°' 808 | 809 | if domain[0] == "vacuum": 810 | command_url = simpleschedulerconf.HASSIO_URL + "/services/vacuum/start" 811 | command = "Starting" 812 | 813 | if domain[0] == "script" and value != "": 814 | params = base64.b32decode(passedvalue).decode() 815 | command_url = simpleschedulerconf.HASSIO_URL + "/services/script/"+eid.replace("script.","") 816 | postdata = params 817 | command = "Executing" 818 | extra = "with params " + params 819 | 820 | else: 821 | 822 | if domain[0] == "vacuum": 823 | command_url = simpleschedulerconf.HASSIO_URL + "/services/vacuum/return_to_base" 824 | command = "Returning to base" 825 | 826 | if domain[0] == "cover": 827 | command_url = simpleschedulerconf.HASSIO_URL + "/services/cover/close_cover" 828 | command = "Closing" 829 | 830 | if domain[0] == "valve": 831 | command_url = simpleschedulerconf.HASSIO_URL + "/services/valve/close_valve" 832 | command = "Closing" 833 | 834 | if domain[0] == "scene": 835 | # command_url = simpleschedulerconf.HASSIO_URL + "/services/scene/turn_on" 836 | # command = "Turning ON" 837 | continue 838 | 839 | if domain[0] == "button": 840 | command_url = simpleschedulerconf.HASSIO_URL + "/services/button/press" 841 | command = "Pressing" 842 | 843 | if domain[0] == "input_button": 844 | command_url = simpleschedulerconf.HASSIO_URL + "/services/input_button/press" 845 | command = "Pressing" 846 | 847 | printlog("SCHED: %s [%s] %s" % (command, friendly_name.get(eid, eid), extra)) 848 | call_ha_api(command_url, postdata) 849 | 850 | if domain[0] == "climate" and value != "": 851 | if value[0] != "O": 852 | command_url = simpleschedulerconf.HASSIO_URL + "/services/climate/set_temperature" 853 | postdata = '{"entity_id":"%s","temperature":"%s"}' % (eid, value) 854 | call_ha_api(command_url, postdata) 855 | command = "Setting" 856 | extra = "temperature to " + value + '°' 857 | printlog("SCHED: %s [%s] %s" % (command, friendly_name.get(eid, eid), extra)) 858 | 859 | if domain[0] == "humidifier" and value != "": 860 | command_url = simpleschedulerconf.HASSIO_URL + "/services/humidifier/set_humidity" 861 | postdata = '{"entity_id":"%s","humidity":"%s"}' % (eid, value) 862 | call_ha_api(command_url, postdata) 863 | command = "Setting" 864 | extra = "humidity to " + value + '%' 865 | printlog("SCHED: %s [%s] %s" % (command, friendly_name.get(eid, eid), extra)) 866 | 867 | return True 868 | 869 | def notify_on_error(message): 870 | response = "" 871 | opt : dict = get_options() 872 | notifier = opt.get('notifier', '') 873 | 874 | if notifier != no_notification_placeholder and len(notifier)>0 : 875 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 876 | command_url = simpleschedulerconf.HASSIO_URL + "/services/notify/" + notifier 877 | post_data = '{"message":"%s"}' % message 878 | try: 879 | r = requests.post(url=command_url, data=post_data, headers=headers, timeout=request_timeout) 880 | if r.content: 881 | response = r.content.decode().lower() 882 | printlog("SCHED: ↳ Notification sent to %s" % notifier) 883 | if r.status_code != 200: 884 | if "message" in response: 885 | json_response = json.loads(r.content) 886 | response = json_response['message'] 887 | printlog("ERROR: ↳ Notification NOT sent - " +response ) 888 | except Exception as e: 889 | printlog("ERROR: ↳ Something went wrong ",e ) 890 | return True 891 | 892 | def get_notifiers(): 893 | notifiers = [no_notification_placeholder] 894 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 895 | command_url = simpleschedulerconf.HASSIO_URL + "/services" 896 | try: 897 | response = requests.get(url=command_url, headers=headers, timeout=request_timeout) 898 | if response.status_code == 200 : 899 | services = response.json() 900 | for domain in services: 901 | if domain['domain'] == 'notify': 902 | for service in domain['services']: 903 | if service not in ["send_message","notify"]: notifiers.append(service) 904 | else: 905 | if "message" in response: 906 | json_response = json.loads(response.content) 907 | response = json_response['message'] 908 | printlog("ERROR: Something went wrong getting notifiers - %s " % response) 909 | except Exception as e: 910 | printlog("ERROR: Something went wrong getting notifiers",e ) 911 | 912 | return notifiers 913 | 914 | 915 | def is_a_retry_domain(entity): 916 | response = True 917 | if "scene." in entity: response = False 918 | if "script." in entity: response = False 919 | if "automation." in entity: response = False 920 | if "media_player." in entity: response = False 921 | if "camera." in entity: response = False 922 | if "button." in entity: response = False 923 | if "input_button." in entity: response = False 924 | return response 925 | 926 | 927 | def encode_braces_to_base32(input_str): 928 | def encode_match(match): 929 | json_str = match.group(1) 930 | json_str_clean = json_str.replace('\n', '').replace('\r', '') 931 | encoded = base64.b32encode(json_str_clean.encode()).decode() 932 | return f'J{encoded}' 933 | pattern = r'J({.*?})' 934 | result = re.sub(pattern, encode_match, input_str, flags=re.DOTALL) 935 | return result 936 | 937 | def get_events_array(s): 938 | s = encode_braces_to_base32(s) 939 | s = s.replace('\n', '').replace('\r', '') 940 | s = s.upper().replace(',', ' ').replace(';', ' ').strip() 941 | s = re.sub(' +', ' ', s) 942 | events = s.split(' ') 943 | return events 944 | 945 | 946 | def evaluate_event_time(s, sunrise, sunset): 947 | event = "" 948 | sunrise_day = "" 949 | sunset_day = "" 950 | 951 | s = s.replace('.', ':') 952 | if sunrise: 953 | sunrise_day = sunrise.strftime("%d") 954 | if sunset: 955 | sunset_day = sunset.strftime("%d") 956 | today = datetime.now().strftime("%d") 957 | if len(s) > 3: 958 | p = s.upper().split('>') 959 | event = p[0] 960 | operator = "~" 961 | if event[:3] == "SUN": 962 | if event.find('+') != -1: operator = "+" 963 | if event.find('-') != -1: operator = "-" 964 | eventime = event.split(operator) 965 | event = "" 966 | if eventime[0] == "SUNRISE" and sunrise_day == today: 967 | event = sunrise.strftime("%H:%M") 968 | if eventime[0] == "SUNSET" and sunset_day == today: 969 | event = sunset.strftime("%H:%M") 970 | if event != "" and len(eventime) > 1: 971 | hm = event.split(":") 972 | if operator == '+': 973 | event = (datetime(2022, 1, 1, int(hm[0]), int(hm[1])) + timedelta( 974 | minutes=int(eventime[1]))).strftime("%H:%M") 975 | else: 976 | event = (datetime(2022, 1, 1, int(hm[0]), int(hm[1])) - timedelta( 977 | minutes=int(eventime[1]))).strftime("%H:%M") 978 | if event: 979 | hm = event.split(":") 980 | if 0 <= int(hm[0]) < 24 and 0 <= int(hm[1]) < 60: 981 | event = datetime(2022, 1, 1, int(hm[0]), int(hm[1])).strftime("%H:%M") # fix missing leading zeroes 982 | 983 | return event 984 | 985 | 986 | def get_sun(tz, sunrise="", sunset=""): 987 | if tz: 988 | mytimezone = pytz.timezone(tz) 989 | url = simpleschedulerconf.HASSIO_URL + "/states/sun.sun" 990 | headers = {'content-type': 'application/json', 991 | 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 992 | try: 993 | r = requests.get(url=url, headers=headers, timeout=request_timeout) 994 | result = r.json() 995 | response = result['attributes'] 996 | sunrise = datetime.fromisoformat(response['next_rising']).astimezone(mytimezone) 997 | sunset = datetime.fromisoformat(response['next_setting']).astimezone(mytimezone) 998 | except Exception as e: 999 | printlog("ERROR: Unable to obtain sun info from Home Assistant", e) 1000 | return sunrise, sunset 1001 | 1002 | 1003 | def get_ha_timezone(): 1004 | response = "" 1005 | url = simpleschedulerconf.HASSIO_URL + "/config" 1006 | headers = {'content-type': 'application/json', 'Authorization': 'Bearer ' + simpleschedulerconf.SUPERVISOR_TOKEN} 1007 | try: 1008 | r = requests.get(url=url, headers=headers, timeout=request_timeout) 1009 | result = r.json() 1010 | response = result['time_zone'] 1011 | except Exception as e: 1012 | printlog("ERROR: Unable to obtain timezone from Home Assistant",e) 1013 | else: 1014 | try: 1015 | if not response: 1016 | response = os.environ["TZ"] 1017 | except Exception as e: 1018 | printlog("ERROR: Unable to obtain timezone from OS",e) 1019 | 1020 | return response 1021 | 1022 | def printlog(message,e=None ): 1023 | fullrow2 = "" 1024 | t = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 1025 | fullrow = "[%s] %s" % (t, message) 1026 | print(fullrow) 1027 | if e: 1028 | fullrow2 = "[%s] %s" % (t, e) 1029 | print(fullrow2) 1030 | 1031 | if not os.path.exists(simpleschedulerconf.json_folder): 1032 | os.makedirs(simpleschedulerconf.json_folder) 1033 | 1034 | logfilepath = os.path.join(simpleschedulerconf.json_folder, "simplescheduler.log") 1035 | with open(logfilepath, "a", encoding='utf-8') as logfile: 1036 | logfile.write(fullrow + "\n") 1037 | if e: 1038 | logfile.write(fullrow2 + "\n") 1039 | 1040 | 1041 | def init(): 1042 | global options 1043 | global weekday 1044 | global schedulers_list 1045 | global ha_timezone 1046 | global domain_list 1047 | global mqtt_enabled 1048 | 1049 | options = get_options() 1050 | weekday = \ 1051 | [ 1052 | options['translations']['text_sunday'][:2], 1053 | options['translations']['text_monday'][:2], 1054 | options['translations']['text_tuesday'][:2], 1055 | options['translations']['text_wednesday'][:2], 1056 | options['translations']['text_thursday'][:2], 1057 | options['translations']['text_friday'][:2], 1058 | options['translations']['text_saturday'][:2], 1059 | options['translations']['text_sunday'][:2] 1060 | ] 1061 | 1062 | # domain_list = get_domains() 1063 | 1064 | schedulers_list = load_json_schedulers() 1065 | 1066 | ha_timezone = get_ha_timezone() 1067 | 1068 | mqtt_enabled = options['MQTT']['enabled'] 1069 | 1070 | 1071 | def run_flask(): 1072 | try: 1073 | # Disable Flask Messages 1074 | log = logging.getLogger('werkzeug') 1075 | log.disabled = True 1076 | flask.cli.show_server_banner = lambda *args: None 1077 | # app.logger.disabled = True 1078 | printlog('STATUS: Starting WebServer') 1079 | app.run(host='0.0.0.0', port=8099, debug=False) 1080 | except Exception as e: 1081 | printlog(f"ERROR: Interface thread crashed: [ {e} ]") 1082 | time.sleep(2) 1083 | 1084 | 1085 | def run_mqtt(): 1086 | global mqttclient 1087 | while True: 1088 | if mqtt_enabled: 1089 | try: 1090 | printlog('STATUS: Starting MQTT') 1091 | mqttclient.on_connect = on_connect 1092 | mqttclient.on_message = on_message 1093 | mqttclient.will_set(lwt_topic, payload="offline", qos=0, retain=True) 1094 | mqttclient.username_pw_set(options['MQTT']['username'], options['MQTT']['password']) 1095 | mqttclient.connect(options['MQTT']['server'], int(options['MQTT']['port']), 60) 1096 | mqttclient.loop_start() 1097 | mqttclient.publish(lwt_topic, payload="online", qos=0, retain=True) 1098 | mqtt_send_config(mqttclient) 1099 | while mqtt_enabled: 1100 | time.sleep(1) 1101 | printlog("STATUS: MQTT disabled, disconnecting...") 1102 | mqttclient.publish(lwt_topic, payload="offline", qos=0, retain=True) 1103 | mqttclient.loop_stop() 1104 | mqttclient.disconnect() 1105 | except Exception as e: 1106 | printlog(f"ERROR: MQTT thread error: [ {e} ]") 1107 | time.sleep(2) 1108 | else: 1109 | time.sleep(1) 1110 | 1111 | 1112 | def run_scheduler(): 1113 | try: 1114 | command_queue = {} 1115 | sunrise, sunset = "", "" 1116 | 1117 | printlog('STATUS: Starting Scheduler') 1118 | 1119 | while True: 1120 | now = datetime.now() 1121 | seconds = now.strftime("%S") 1122 | if seconds == '00': 1123 | opt : dict = get_options() 1124 | max_retry = min(max(int(opt.get('max_retry', 3)), 0), 5) 1125 | 1126 | current_time = now.strftime("%H:%M") 1127 | current_dow = now.strftime("%w") 1128 | if current_dow == '0': 1129 | current_dow = '7' 1130 | 1131 | friendly_name = get_switch_friendly_names() 1132 | 1133 | if current_time == "00:01" or not sunrise or not sunset: 1134 | if opt.get('debug'): 1135 | printlog('DEBUG: Retrieving sunrise and sunset') 1136 | sunrise, sunset = get_sun(get_ha_timezone()) 1137 | 1138 | for s in load_json_schedulers(): 1139 | if not s.get('enabled'): 1140 | continue 1141 | 1142 | template = s.get('template', '') 1143 | dont_retry = s.get('dontretry', 0) 1144 | week_onoff = s.get('weekly') 1145 | 1146 | if opt.get('debug'): 1147 | printlog(f"DEBUG: Parsing [{s['name']}] [{s['id']}]") 1148 | 1149 | if week_onoff: 1150 | s['weekly'] = '' 1151 | s['on_tod'] = week_onoff.get(f'on_{current_dow}', '') 1152 | s['off_tod'] = week_onoff.get(f'off_{current_dow}', '') 1153 | s['on_dow'] = s['off_dow'] = current_dow 1154 | 1155 | for action in ['on', 'off']: 1156 | if current_dow not in s.get(f'{action}_dow', ''): 1157 | continue 1158 | 1159 | condition = True 1160 | if template: 1161 | condition = evaluate_template(template) 1162 | if opt.get('debug'): 1163 | printlog(f"DEBUG: Evaluating template for [{s['name']}]: {condition}") 1164 | if not condition: 1165 | tod = s.get(f'{action}_tod_false', '') 1166 | else: 1167 | tod = s.get(f'{action}_tod', '') 1168 | 1169 | for e in get_events_array(tod): 1170 | p = e.upper().split('>') 1171 | event_time = evaluate_event_time(p[0], sunrise, sunset) 1172 | value = p[1][1:] if len(p) > 1 else "" 1173 | 1174 | if event_time != current_time: 1175 | continue 1176 | 1177 | printlog(f"SCHED: Executing {action.upper()} actions for [{s['name']}]") 1178 | call_ha(s['entity_id'], action, value, friendly_name) 1179 | 1180 | for entity in s['entity_id']: 1181 | skip = len(value) > 0 and value[0] == 'O' 1182 | if skip or dont_retry or max_retry <= 0: 1183 | continue 1184 | if is_a_retry_domain(entity): 1185 | command_queue[uuid.uuid4().hex] = { 1186 | "entity_id": entity, 1187 | "sched_id": s['id'], 1188 | "state": action, 1189 | "value": value, 1190 | "countdown": max_retry, 1191 | "max_retry": max_retry 1192 | } 1193 | 1194 | time.sleep(5) 1195 | 1196 | if opt.get('debug'): 1197 | printlog(f"DEBUG: Max Retry: {max_retry}") 1198 | printlog(f"DEBUG: Starting Queue management - Queue length: {len(command_queue)}") 1199 | 1200 | for key in list(command_queue): 1201 | item = command_queue[key] 1202 | status = get_entity_status(item['entity_id'], True) 1203 | 1204 | if opt.get('debug'): 1205 | printlog(f"DEBUG: ID:{key} | Entity status:{status.upper()} | Queue item:{item}") 1206 | 1207 | name = friendly_name.get(item['entity_id'], item['entity_id']) 1208 | attempt = item['max_retry'] - item['countdown'] + 1 1209 | 1210 | if status == 'unavailable': 1211 | printlog(f"SCHED: [{name}] is unavailable. Attempt {attempt} of {item['max_retry']}") 1212 | elif status != item['state']: 1213 | printlog(f"SCHED: Failed to set [{name}]. Retry {attempt} of {item['max_retry']}") 1214 | call_ha(item['entity_id'], item['state'], item['value'], friendly_name) 1215 | else: 1216 | printlog(f"SCHED: [{name}] is {status.upper()} as requested!") 1217 | command_queue.pop(key) 1218 | continue # Skip countdown update 1219 | 1220 | item['countdown'] -= 1 1221 | if item['countdown'] <= 0: 1222 | msg = f"SCHED: Giving up on [{name}]" 1223 | printlog(msg) 1224 | notify_on_error(msg) 1225 | command_queue.pop(key) 1226 | 1227 | if opt.get('debug'): 1228 | printlog(f"DEBUG: Finished Queue management - Queue length: {len(command_queue)}") 1229 | 1230 | time.sleep(1) 1231 | 1232 | except Exception as e: 1233 | printlog(f"ERROR: Scheduler thread crashed: [ {e} ]") 1234 | time.sleep(2) 1235 | 1236 | def start_thread(target): 1237 | thread = threading.Thread(target=target, daemon=True) 1238 | thread.start() 1239 | return thread 1240 | 1241 | 1242 | if __name__ == '__main__': 1243 | 1244 | printlog('STATUS: Starting main program') 1245 | 1246 | init() 1247 | 1248 | if options['debug']: 1249 | printlog(" QUESTION: What do you get if you multiply six by nine?") 1250 | printlog(" ANSWER: 42") 1251 | 1252 | flask_thread = start_thread(run_flask) 1253 | scheduler_thread = start_thread(run_scheduler) 1254 | mqtt_thread = start_thread(run_mqtt) 1255 | 1256 | try: 1257 | while True: 1258 | if not flask_thread.is_alive(): 1259 | printlog("Restarting interface thread...") 1260 | flask_thread = start_thread(run_flask) 1261 | 1262 | if not scheduler_thread.is_alive(): 1263 | printlog("Restarting scheduler thread...") 1264 | scheduler_thread = start_thread(run_scheduler) 1265 | 1266 | if not mqtt_thread.is_alive() : 1267 | printlog("Restarting MQTT thread...") 1268 | mqtt_thread = start_thread(run_mqtt) 1269 | 1270 | time.sleep(1) 1271 | 1272 | 1273 | except KeyboardInterrupt: 1274 | printlog("Shutting down...") 1275 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/options.default: -------------------------------------------------------------------------------- 1 | { 2 | "translations": { 3 | "text_monday": "Monday", 4 | "text_tuesday": "Tuesday", 5 | "text_wednesday": "Wednesday", 6 | "text_thursday": "Thursday", 7 | "text_friday": "Friday", 8 | "text_saturday": "Saturday", 9 | "text_sunday": "Sunday", 10 | "text_ON": "ON", 11 | "text_OFF": "OFF", 12 | "text_save": "Save", 13 | "text_enabled": "Enabled", 14 | "text_device": "Device", 15 | "text_name": "Name" 16 | }, 17 | "components": { 18 | "light": true, 19 | "scene": true, 20 | "switch": true, 21 | "script": true, 22 | "camera": true, 23 | "climate": true, 24 | "cover": true, 25 | "vacuum": true, 26 | "fan": true, 27 | "humidifier": true, 28 | "valve": true, 29 | "automation": true, 30 | "input_boolean": true, 31 | "input_button": true, 32 | "button": true, 33 | "media_player": true 34 | }, 35 | "MQTT": { 36 | "enabled": false, 37 | "server": "core-mosquitto", 38 | "port": "1883", 39 | "username": "", 40 | "password": "" 41 | }, 42 | "max_retry": "3", 43 | "notifier": " - disabled - ", 44 | "details_uncovered": true, 45 | "debug": false, 46 | "excluded_entities": "" 47 | } 48 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/scheduler.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | # ============================================================================== 3 | # 4 | # Home Assistant Add-on: SimpleScheduler 5 | # 6 | # ============================================================================== 7 | 8 | bashio::log.info "Running scheduler.sh" 9 | 10 | python3 /simplescheduler/main.py 11 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/simpleschedulerconf.py: -------------------------------------------------------------------------------- 1 | import os 2 | json_folder = "/share/simplescheduler/" 3 | SUPERVISOR_TOKEN = os.environ["SUPERVISOR_TOKEN"] 4 | HASSIO_URL = os.environ.get("HASSIO_URL","http://hassio/homeassistant/api") 5 | 6 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/templates/config.html: -------------------------------------------------------------------------------- 1 | 2 | GENERAL 3 | 4 | 5 | Max retry 6 | 7 | 8 | 9 | 10 | Notify on error: 11 | 12 | 17 | 18 | 19 | 20 | 21 | Excluded entities 22 | 23 | 24 | 25 | 26 | Details uncovered 27 | 28 | 29 | 30 | 31 | Debug mode 32 | 33 | 34 | 35 | 36 | 37 | MQTT 38 | {% for t in o.MQTT %} 39 | 40 | {{ t }} 41 | {% if t=="enabled": %} 42 | 43 | {% else %} 44 | 45 | {% endif %} 46 | 47 | 48 | {% endfor %} 49 | 50 | DOMAINS 51 | {% for t in d %} 52 | 53 | {{ t }} 54 | 55 | 56 | 57 | {% endfor %} 58 | 59 | TRANSLATIONS 60 | {% for t in o.translations %} 61 | 62 | {{ t | replace("text_","") | upper }} 63 | 64 | 65 | 66 | {% endfor %} 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/templates/edit.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ p.id }}

4 |
5 | 6 | 7 | 8 |
9 | 10 |
11 |
12 | 13 |     14 | 15 |
16 | 17 | 18 |
19 | {% if p.entity_id %} 20 | {% for e in p.entity_id %} 21 |
22 |
23 | 24 |
25 |
26 |
27 | {% endfor %} 28 | {% endif %} 29 |
30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 | 39 |
40 |
41 | 42 | {% if p.weekly: %} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
{{ o.translations.text_ON }} {{ o.translations.text_OFF }}
{{ o.translations.text_monday[:2] }}
{{ o.translations.text_tuesday[:2] }}
{{ o.translations.text_wednesday[:2] }}
{{ o.translations.text_thursday[:2] }}
{{ o.translations.text_friday[:2] }}
{{ o.translations.text_saturday[:2] }}
{{ o.translations.text_sunday[:2] }}
87 | {% elif p.recurring: %} 88 | 89 | 90 | 91 | 92 | 93 | 94 |
95 | {% for wd in range(1, 8): %} 96 | 97 | {% endfor%} 98 |
99 | 100 |
101 |
102 |
103 |
104 | 105 |
106 |
107 |
108 |
109 |
110 | 111 |
112 |
113 |
114 |
115 |
116 | 117 |
118 |
119 |
120 | 121 |
122 |
123 | 124 | 125 | 126 | 127 | 128 |
129 | {% for wd in range(1, 8): %} 130 | 131 | {% endfor%} 132 |
133 | 134 |
135 |
136 |
137 |
138 | 139 |
140 |
141 |
142 |
143 |
144 | 145 |
146 |
147 |
148 |
149 |
150 | 151 |
152 |
153 |
154 | 155 |
156 |
157 | 158 | 159 | 160 | 161 | 162 | {% else %} 163 | 164 | 165 |
166 |
167 | 168 | 169 |
170 |
171 | 172 | 173 |
174 |
175 | 176 |
177 | {% for wd in range(1, 8): %} 178 | 179 | {% endfor%} 180 |
181 | 182 |
183 |
184 | 185 | 186 |
187 |
188 | 189 | 190 |
191 |
192 | 193 |
194 | {% for wd in range(1, 8): %} 195 | 196 | {% endfor%} 197 |
198 | {% endif %} 199 |
200 |
201 | 202 |
203 | 204 | {% if is_new==False: %} 205 | 206 | 207 | {% endif %} 208 |
209 | 210 |
211 |
212 |
213 |
214 |
215 | 216 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/templates/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arthurdent75/SimpleScheduler/7114f12cf2dce24e30b92c015bc271d09ff24b5b/rootfs/simplescheduler/templates/favicon.ico -------------------------------------------------------------------------------- /rootfs/simplescheduler/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Simple Scheduler for HA 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 26 | 27 | 28 | 29 | 30 |
31 | 47 | 48 |
49 | 50 |
51 |
52 | 53 | 54 | 55 | 63 | 64 | 65 | 66 | 67 | {% if groups %} 68 | {% for key, group in groups.items() %} 69 | {% set group_id = "group-" + key %} 70 | 71 | 72 | 79 | 80 | {% endfor %} 81 | {% endif %} 82 | 83 | {% for s in data %} 84 | 85 | {% set ns_id = namespace(value = None) %} 86 | {% if groups %} 87 | {% for key, group in groups.items() %} 88 | {% for value in group["values"] %} 89 | {% if s.id|string|lower|trim == value|string|lower|trim %} 90 | {% set ns_id.value = key %} 91 | {% endif %} 92 | {% endfor %} 93 | {% endfor %} 94 | {% endif %} 95 | {% set group_id = ns_id.value %} 96 | 97 | 98 | 99 | {% if s.weekly %} 100 | 101 | {% elif s.recurring %} 102 | 103 | {% else %} 104 | 105 | {% endif %} 106 | 107 | 108 | 109 | 113 | 123 | {% if s.weekly %} 124 | 152 | {% elif s.recurring %} 153 | 179 | {% else %} 180 | 223 | {% endif %} 224 | 225 | 226 | {% endfor %} 227 | 228 | 229 |
56 | SimpleScheduler 57 | 58 | 59 | 60 | 61 | 62 |
73 | 74 | {{ group.name }} 75 | 76 | 77 | 78 |
{% if s.template %}{% endif %} 110 | 111 | 112 | 114 |

{{ s.name }}

115 |
116 | {% if s.entity_id %} 117 | {% for e in s.entity_id %} 118 | {{ friendlynames[e] }} 119 | {% endfor %} 120 | {% endif %} 121 |
122 |
125 |
126 |
127 |
128 | {% for wd in range(1, 8): %} 129 |
130 | {{ weekday[wd] }} 131 |
132 | {% endfor%} 133 |
134 |
135 |
{{ o.translations.text_ON }}
136 | {% for wd in range(1, 8): %} 137 |
138 | {{ format_event(s.weekly['on_'+wd|string], True)|safe }} 139 |
140 | {% endfor%} 141 |
142 |
143 |
{{ o.translations.text_OFF }}
144 | {% for wd in range(1, 8): %} 145 |
146 | {{ format_event(s.weekly['off_'+wd|string], False)|safe }} 147 |
148 | {% endfor%} 149 |
150 |
151 |
154 |
155 |
156 |
157 | {% if s.on_dow and s.on_tod %} 158 |
159 | {{ s.recurring.on_start }} {{ s.recurring.on_end }} 160 | {{ s.recurring.on_interval }}m 161 |
162 | 163 |
{{ get_friendly_html_dow(s.on_dow,True)|safe }}
164 | {% endif %} 165 |
166 |
167 | {% if s.off_dow and s.off_tod %} 168 |
169 | {{ s.recurring.off_start }} {{ s.recurring.off_end }} 170 | {{ s.recurring.off_interval }}m 171 |
172 | 173 |
{{ get_friendly_html_dow(s.off_dow,False)|safe }}
174 | {% endif %} 175 |
176 |
177 |
178 |
181 |
182 |
183 |
184 | {% if s.on_dow and ( s.on_tod or s.on_tod_false ) %} 185 |
186 | {% if s.on_tod_false and s.on_tod %} 187 | 188 | {% endif %} 189 | {{ format_event(s.on_tod, True)|safe }} 190 |
191 | {% if s.on_tod_false %} 192 |
193 |
194 | 195 | {{ format_event(s.on_tod_false, True)|safe }} 196 |
197 |
198 | {% endif %} 199 |
{{ get_friendly_html_dow(s.on_dow,True)|safe }}
200 | {% endif %} 201 |
202 |
203 | {% if s.off_dow and ( s.off_tod or s.off_tod_false ) %} 204 |
205 | {% if s.off_tod_false and s.off_tod %} 206 | 207 | {% endif %} 208 | {{ format_event(s.off_tod, False)|safe }}
209 | {% if s.off_tod_false %} 210 |
211 |
212 | 213 | {{ format_event(s.off_tod_false, True)|safe }} 214 |
215 |
216 | {% endif %} 217 |
{{ get_friendly_html_dow(s.off_dow,False)|safe }}
218 | {% endif %} 219 |
220 |
221 |
222 |
230 |
231 | 232 |
233 | 234 |
235 | 236 |
237 | 238 |
239 |
240 |

241 | {{ statusbarinfo.sunrise }} 242 | {{ statusbarinfo.sunset }} 243 | {{ statusbarinfo.timezone }} 244 | MQTT: {{ statusbarinfo.mqtt }} 245 | 246 |

247 |
248 |
249 |
250 | 251 |
252 |
253 |

254 | 		
255 | 	
256 |
257 |
258 | 259 | 826 | 827 | 828 | 829 | 830 | -------------------------------------------------------------------------------- /rootfs/simplescheduler/templates/new.html: -------------------------------------------------------------------------------- 1 |
2 |
Daily
3 |
Weekly
4 |
Recurring
-------------------------------------------------------------------------------- /rootfs/simplescheduler/templates/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --maincolor: #007bff; 3 | } 4 | 5 | body { 6 | font-family: Roboto, Noto, "Noto Sans", sans-serif 7 | 8 | } 9 | 10 | H5 {margin-bottom: 0 ; line-height: 14px; } 11 | 12 | .main-color { color: var(--maincolor)!important; } 13 | 14 | div.content { margin-bottom: 2em; } 15 | 16 | 17 | .table td, .table th { 18 | vertical-align: top; 19 | border-bottom: 1px solid var(--maincolor); 20 | border-top: none; 21 | 22 | } 23 | 24 | THEAD { 25 | line-height: 3em; 26 | } 27 | 28 | #sidebar { 29 | display:none; 30 | min-width: 250px; 31 | max-width: 50%; 32 | height: 100%; 33 | position: fixed; 34 | top: 0; 35 | right: 0; 36 | padding: 1em 1em 1em 2em; 37 | z-index: 9999; 38 | background-color: rgba(255,255,255,0.98); 39 | box-shadow: 5px 5px 18px 0px #000; 40 | overflow-y: visible; 41 | overflow-x: visible; 42 | } 43 | 44 | .edit-form > div { 45 | margin: 1em 0; 46 | } 47 | 48 | .form-row span.mdi { font-size: 14px; } 49 | 50 | .btn-default { color: white;} 51 | 52 | .dowIcon { 53 | border: 0px solid ; 54 | border-radius: 20px; 55 | background: grey; 56 | color: white; 57 | font-size: 0.8rem; 58 | width: 1.8rem; 59 | height: 1.8rem; 60 | line-height: 1.8rem; 61 | text-align: center; 62 | margin-right: 0.1em; 63 | display: inline-block; 64 | text-shadow: 1px 1px 1px #333; 65 | box-shadow: inset -3px -4px 6px #00000077; 66 | } 67 | 68 | .dowHiglightR { background: red ; } 69 | .dowHiglightG { background: green ; } 70 | 71 | .icon-space {width: 32px;} 72 | 73 | .text-green {color:green;} 74 | .text-red {color:red;} 75 | .text-white {color:white;} 76 | .text-center {text-align:center;} 77 | 78 | .bg-primary { 79 | color:white; 80 | background-color: #03a9f4!important; 81 | } 82 | 83 | .titlebar_span { 84 | font-size: 20px; 85 | margin-right: 1em; 86 | font-weight: 400; 87 | /* text-shadow: 1px 1px 3px #000; */ 88 | } 89 | 90 | .titlebar_span button { 91 | box-shadow: 2px 2px 2px 0px #00000077; 92 | font-size: 1.25em; 93 | line-height: 1em; 94 | } 95 | 96 | div.row-title P { 97 | font-size: 1.2em; 98 | line-height: 1.2em; 99 | margin-bottom: 0; 100 | } 101 | div.edit-section-label { 102 | width: 100%; 103 | border-bottom: 1px solid #777; 104 | margin-top: 1em; 105 | } 106 | div.edit-section-label label{ 107 | line-height: 1em; 108 | font-weight: bold; 109 | } 110 | 111 | .btn-circle.btn-xl { 112 | width: 70px; 113 | height: 70px; 114 | padding: 10px 16px; 115 | border-radius: 35px; 116 | font-size: 24px; 117 | line-height: 1.33; 118 | opacity: 0.9; 119 | } 120 | 121 | .btn-circle { 122 | width: 30px; 123 | height: 30px; 124 | padding: 6px 0px; 125 | border-radius: 15px; 126 | text-align: center; 127 | font-size: 12px; 128 | line-height: 1.42857; 129 | background: navy; 130 | color: white; 131 | box-shadow: 2px 2px 10px 0px #777; 132 | } 133 | 134 | .floating-bottom-right { 135 | position: fixed; 136 | bottom: 5%; 137 | right: 5%; 138 | } 139 | 140 | 141 | 142 | td.name_col{ 143 | width: 25%; 144 | max-width: 400px; 145 | } 146 | 147 | .event-list > span { 148 | margin-right: 0.5rem; 149 | margin-bottom: 5px; 150 | padding: 0 0.5rem; 151 | font-size: 1rem; 152 | line-height: 1.6rem; 153 | float: left; 154 | border: 1px solid black; 155 | border-radius: 0.2em; 156 | } 157 | 158 | .event-list.text-green > span { 159 | border-color:green; 160 | } 161 | 162 | .event-list.text-red > span { 163 | border-color:red; 164 | } 165 | 166 | span.event-type-b { 167 | font-size: 1rem; 168 | color: #d39e00; 169 | margin-left: 0.2rem; 170 | } 171 | 172 | span.event-type-t { 173 | font-size: 1rem; 174 | color: #8b442b; 175 | margin-left: 0.1rem; 176 | } 177 | 178 | span.event-type-to { 179 | font-size: 1rem; 180 | color: #9c27b0; 181 | margin-left: 0.1rem; 182 | } 183 | 184 | span.event-type-h { 185 | font-size: 1rem; 186 | color: blue; 187 | margin-left: 0.1rem; 188 | } 189 | 190 | span.event-type-p { 191 | font-size: 1rem; 192 | color: #2196f3; 193 | margin-left: 0.1rem; 194 | } 195 | 196 | span.event-type-j { 197 | font-size: 1rem; 198 | color: #000000; 199 | margin-left: 0.2rem; 200 | } 201 | 202 | div.colorsample { 203 | display: inline-block; 204 | width: 1em; 205 | height: 1em; 206 | box-shadow: 1px 1px 2px #777; 207 | } 208 | 209 | span.colorkelvin { 210 | color: black; 211 | font-size: 0.8em; 212 | } 213 | 214 | footer { 215 | position: fixed; 216 | bottom: 0; 217 | right: 0; 218 | margin:0; 219 | padding: 0; 220 | width: 100%; 221 | height: 2em; 222 | line-height: 2em; 223 | font-size: 1em; 224 | background-color: grey; 225 | color: black 226 | } 227 | 228 | footer .statusbar { 229 | margin-left: 1em; 230 | } 231 | 232 | .statusbar_span {margin-right: 2em; } 233 | .statusbar_span .mdi {font-size:1.2em; } 234 | 235 | #showlog {cursor:pointer;} 236 | 237 | #log_wrapper { 238 | z-index: 9999; 239 | display: none; 240 | position: fixed; 241 | top: 10%; 242 | left: 10%; 243 | width: 80%; 244 | height: 80%; 245 | background-color: white; 246 | padding: 0; 247 | margin: 0; 248 | border: 1px solid #ccc; 249 | overflow: hidden; 250 | box-shadow: 5px 5px 15px 0px #777; 251 | } 252 | 253 | #log_wrapper > div { 254 | position: relative; 255 | width: 98%; 256 | height: 96%; 257 | margin: 1%; 258 | padding: 0; 259 | overflow: hidden; 260 | } 261 | 262 | #logcontent { 263 | font-size: 1em; 264 | overflow: scroll; 265 | width: 100%; 266 | height: 100%; 267 | margin: 0; 268 | padding: 0; 269 | } 270 | 271 | #closelog { 272 | position: absolute; 273 | top: 1em; 274 | right: 3em; 275 | } 276 | 277 | #block_background { 278 | position: fixed; 279 | top: 0; 280 | bottom: 0; 281 | height: 0; 282 | width: 0; 283 | background-color: #77777700; 284 | z-index: 9998; 285 | transition: background-color 0.25s; 286 | } 287 | 288 | .week_table { 289 | display: none; 290 | width: 100%; 291 | margin: 0px 0px 1em; 292 | } 293 | 294 | .week_table_row { 295 | display: table-row; 296 | } 297 | 298 | .week_table_header .week_table_cell { 299 | border-bottom: 1px solid #777 !important; 300 | } 301 | 302 | .week_table_cell { 303 | display: table-cell; 304 | padding: 3px 10px; 305 | border: none; 306 | text-align: left; 307 | border-bottom: 1px solid #ddd; 308 | } 309 | 310 | .week_table_cell > span { 311 | display: block; 312 | } 313 | 314 | .d_mode .week_table_cell { 315 | border-bottom: none; 316 | width: 50%; 317 | } 318 | 319 | .week_table.d_mode { 320 | margin: 0px 0px; 321 | } 322 | 323 | .img-add-new { 324 | display: block; 325 | width: 90%; 326 | margin: 1em auto; 327 | height: auto; 328 | min-height: 3em; 329 | box-shadow: 2px 2px 5px -2px #777; 330 | cursor: pointer; 331 | line-height: 3em; 332 | padding: 0em 1em; 333 | } 334 | 335 | .img-add-new > SPAN { 336 | vertical-align: middle; 337 | } 338 | 339 | .badge{ 340 | color: white; 341 | text-shadow: 1px 1px 1px #333; 342 | } 343 | 344 | .hidden_cell {display: none; } 345 | 346 | .drag_icon {cursor: move;} 347 | 348 | .fit { 349 | white-space: nowrap; 350 | width: 1%; 351 | } 352 | 353 | p.scheduler_id { 354 | position: absolute; 355 | top: 1.5em; 356 | right: 2em; 357 | margin: 0; 358 | padding: 0; 359 | opacity: .5; 360 | font-size: 0.8em; 361 | } 362 | .col-sm-3 {margin-right: 0.5em; } 363 | .col-sm-3:last-child {margin-right: 0; } 364 | 365 | .form-row { 366 | display: flex; 367 | flex-wrap: nowrap; 368 | } 369 | 370 | #recurring_preview_on, 371 | #recurring_preview_off { 372 | opacity: 0.75; 373 | } 374 | 375 | 376 | #edit_buttons > button:first-child { 377 | margin-right: 1em; 378 | } 379 | 380 | .float-left { float:left} 381 | .float-right { float:right} 382 | 383 | tr.config_section { 384 | background: #777; 385 | font-weight: bold; 386 | color: white; 387 | } 388 | 389 | tr.config_section td { 390 | border: none; 391 | } 392 | 393 | tr.config_item td { 394 | border: none; 395 | } 396 | 397 | #config-form .form-control { 398 | padding: 0.1rem 0.75rem; 399 | } 400 | 401 | tr.config_item td.config_input, 402 | tr.config_item td.config_label { 403 | width: 10%; 404 | min-width: 150px; 405 | padding: 0.25em 1em; 406 | vertical-align: middle; 407 | } 408 | 409 | #notice { 410 | opacity: 0; 411 | height: 0; 412 | margin-top: 0; 413 | color: white; 414 | font-size: 0.8em; 415 | padding: 0.2em; 416 | text-align: center; 417 | transition: all 1s; 418 | } 419 | 420 | .false_condition_section { 421 | display: none; 422 | margin-left: 0.5em; 423 | } 424 | 425 | .input-group-append { 426 | position: absolute; 427 | right: 0; 428 | } 429 | 430 | .ts-wrapper { 431 | width: 100%; 432 | } 433 | 434 | .ts-control { 435 | padding: .375rem .75rem; 436 | overflow: hidden; 437 | white-space: nowrap; 438 | } 439 | 440 | .optgroup-header { 441 | font-weight: bolder; 442 | } 443 | 444 | .ts-dropdown [data-selectable].option { 445 | margin-left: 1em; 446 | } 447 | 448 | #notifier_dropdown { 449 | appearance: auto; 450 | } 451 | 452 | 453 | tr.row-in-group > td:last-child, 454 | tr.row-is-item > td { padding-left: 1em; } 455 | /* tr.row-in-group > td { padding-left: 3em; } */ 456 | 457 | .row-is-group {font-weight: bold; } 458 | .group-icon { cursor: pointer; } 459 | .group-icon { margin-right: 5px; cursor: pointer; } 460 | .edit-icon, .delete-icon { margin-left: 10px; cursor: pointer; } 461 | .group-delete { float: right; } 462 | .group-rename { margin-left: 1em; ursor: pointer; } 463 | tr.row-is-group:hover .group-delete, 464 | tr.row-is-group:hover .group-rename { display:initial; } 465 | 466 | tr.row-is-group, 467 | tr.row-in-group { 468 | border-left: 5px solid #03a9f4; 469 | border-right: 1px solid #03a9f4; 470 | background-color: #f0f6ff 471 | } 472 | tr { 473 | border-left: 5px solid white; 474 | } 475 | 476 | td.drag_cell { 477 | width: 1%; 478 | min-width: 50px; 479 | max-width: 50px; 480 | text-align: center; 481 | padding-left: 0px !important; 482 | padding-right: 0px !important; 483 | } 484 | 485 | #dtable thead tr { border-left: 5px solid #03a9f4;} 486 | 487 | .group-recap-en > div{ 488 | color: white; 489 | margin-left: 2em; 490 | border: 1px solid #03a9f4; 491 | background: #03a9f4; 492 | padding: 0.2em; 493 | font-weight: bold; 494 | border-radius: 5px; 495 | width: 2.5em; 496 | text-align: center; 497 | display: inline-block; 498 | } 499 | .group-recap-dis > div { 500 | color: #03a9f4; 501 | margin-left: 1em; 502 | border: 1px solid #03a9f4; 503 | background: #fff; 504 | padding: 0.2em; 505 | font-weight: bold; 506 | border-radius: 5px; 507 | width: 2.5em; 508 | text-align: center; 509 | display: inline-block; 510 | } 511 | 512 | /* 513 | #resize-handle { 514 | width: 2em; 515 | height: 100px; 516 | background-color: #ccc; 517 | position: absolute; 518 | left: -1em; 519 | top: 50%; 520 | transform: translateY(-50%); 521 | cursor: ew-resize; 522 | border-radius: 5px; 523 | } 524 | */ 525 | 526 | #resize-handle { 527 | width: 2em; 528 | height: 100px; 529 | background-color: trasparent; 530 | position: absolute; 531 | left: -0; 532 | top: 50%; 533 | transform: translateY(-50%); 534 | cursor: ew-resize; 535 | } 536 | 537 | @media screen and (min-width: 1281px) { 538 | html { font-size: 10pt; } 539 | 540 | } 541 | 542 | @media screen and (max-width: 1280px) { 543 | html { font-size: 9pt; } 544 | .form-row span.mdi { font-size: 10px; } 545 | #sidebar { max-width: 50%; } 546 | } 547 | 548 | @media screen and (max-width: 860px) { 549 | 550 | html { font-size: 8pt; } 551 | 552 | #title { display: none; } 553 | 554 | TH { text-align: center; } 555 | 556 | #dtable TD { 557 | display: block; 558 | text-align: center; 559 | border: none !important; 560 | } 561 | 562 | #dtable TD:nth-child(2) { } 563 | 564 | #dtable TR { 565 | border-bottom: 1px solid var(--maincolor); 566 | } 567 | 568 | td.drag_cell { float: left !important; padding: 0; } 569 | 570 | #config-form .input-sm {text-align:center;} 571 | 572 | td.name_col {width: 100%; max-width: none;} 573 | 574 | .event-list > span { float: none; display: inline-block; } 575 | 576 | #sidebar { max-width: 90%; } 577 | 578 | tr.config_item td.config_input, 579 | tr.config_item td.config_label { 580 | width: 100%; 581 | } 582 | 583 | .fit { 584 | white-space: normal; 585 | width: auto; 586 | } 587 | .week_table { margin-bottom: 1em; } 588 | 589 | .d_mode .week_table_cell { display: block; text-align: center; width: 100%; } 590 | 591 | .week_table.d_mode {margin-bottom: 0em; } 592 | 593 | .event-list { 594 | width: 80%; 595 | margin: auto; 596 | } 597 | } 598 | 599 | @media (prefers-color-scheme: dark) { 600 | 601 | BODY { background-color: #111111; color: white; } 602 | 603 | .table td, .table th { border-top: 1px solid #333; color: white; } 604 | 605 | tr { border-left: 5px solid #111111; } 606 | 607 | .week_table_header { color: #e1e1e1; } 608 | 609 | .badge {font-weight: 400; } 610 | 611 | .btn-circle { box-shadow: none; } 612 | 613 | .drag_icon { color: white; } 614 | 615 | #sidebar { 616 | background-color: #222; 617 | box-shadow: 5px 5px 18px 0px #777; 618 | } 619 | 620 | .table-hover>tbody>tr:hover>* { 621 | background-color: rgb(255 255 255 / 15%); 622 | color: white; } 623 | 624 | div.row-title P {color: white;} 625 | 626 | .week_table_cell { border-bottom: 1px solid #777; } 627 | 628 | .text-green { color: lightgreen; } 629 | .text-red { color: #F66; } 630 | 631 | #recurring_preview_on, 632 | #recurring_preview_off 633 | { 634 | opacity: 1; 635 | } 636 | 637 | #logcontent {color: #000} 638 | 639 | span.colorkelvin { 640 | color: white; 641 | } 642 | 643 | tr.row-in-group > td { background-color: #393989; } 644 | tr.row-is-group { background-color: #1d1d66 ; } 645 | 646 | span.event-type-j { color: #ffffff;} 647 | 648 | } --------------------------------------------------------------------------------