diff --git a/roles/airconnect/defaults/main.yml b/roles/airconnect/defaults/main.yml index 710f734..653005a 100644 --- a/roles/airconnect/defaults/main.yml +++ b/roles/airconnect/defaults/main.yml @@ -8,6 +8,8 @@ airconnect_group: name: airconnect gid: 1337 +# for flac: 'flc' +airconnect_codec: mp3:320 airconnect_max_volume: "100" airconnect_upnp: [] diff --git a/roles/airconnect/templates/airupnp.xml.j2 b/roles/airconnect/templates/airupnp.xml.j2 index 9b64590..e45ee46 100644 --- a/roles/airconnect/templates/airupnp.xml.j2 +++ b/roles/airconnect/templates/airupnp.xml.j2 @@ -11,7 +11,7 @@ {{ airconnect_max_volume }} -1 1 - mp3:320 + {{ airconnect_codec }} 1 1 @@ -28,7 +28,9 @@ 0:0 {% for item in airconnect_upnp -%} + {% if 'local_uuid' in item -%} uuid:{{ item.local_uuid }} + {% endif -%} {{ item.name }} {% if 'mac' in item -%} {{ item.mac | upper }} diff --git a/roles/audiobookshelf/files/fix-podcast-date.py b/roles/audiobookshelf/files/fix-podcast-date.py new file mode 100755 index 0000000..b5ac854 --- /dev/null +++ b/roles/audiobookshelf/files/fix-podcast-date.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 + +import argparse +from datetime import datetime +import subprocess +import json +import sys +import shutil +import os +import time + +from loguru import logger +import dateutil.parser +from dateutil.parser._parser import ParserError + +def delete_file(file_path): + try: + os.remove(file_path) + logger.debug(f"deleted: '{file_path}'") + except FileNotFoundError: + pass + + +def replace_file(src, dest): + try: + logger.debug(f"src: {src}, dest: {dest}") + delete_file(dest) + shutil.move(src, dest) + logger.debug(f"replaced '{dest}'") + except (PermissionError, OSError, FileNotFoundError) as e: + logger.error(e) + raise SystemExit(2) + + +def ffprobe_get_tags(file_path): + cmd = [ + "ffprobe", + "-v", "quiet", + file_path, + "-print_format", "json", + "-show_entries", + "stream_tags:format_tags" + ] + try: + p = subprocess.run(cmd, capture_output=True, check=True) + j = json.loads(p.stdout) + return j['format']['tags'] + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + except KeyError as e: + logger.error(f"key {e} for file '{file_path}' not found in ffprobe stdout: {p.stdout.decode()}") + raise SystemExit(2) + + +def ffmpeg_write_date_tag(podcast_file, out_file, iso_date_str): + delete_file(out_file) + + cmd = [ + "ffmpeg", + "-nostdin", + "-y", # overwrite output file + "-i", podcast_file, + #"-c", "copy", + "-metadata", f"date={iso_date_str}", + #"-metadata", f"releasedate={iso_date_str}", + out_file + ] + + try: + p = subprocess.run(cmd, capture_output=True, check=True, stdin=None) + p.check_returncode() + logger.debug(f"output: '{out_file}'") + replace_file(out_file, podcast_file) + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + finally: + delete_file(out_file) + + +def eyeD3_write_date_tag(podcast_file, iso_date_str): + + # import eyeD3 ? + + podcast_dir = os.path.basename(podcast_file) + cover_path = os.path.join(podcast_dir, "cover.jpg") + + cmd = [ + "eyeD3", + "--release-date", iso_date_str, + "--orig-release-date", iso_date_str, + "--recording-date", iso_date_str, + # this overwrites 'release date' i think: + #"--release-year", iso_date_str.split("-")[0], + #"--preserve-file-times" + ] + # if os.path.exists(cover_path): + # cmd.extend(["--add-image", f"{cover_path}:FRONT_COVER"]) + cmd.append(podcast_file) + + logger.debug(" ".join(cmd)) + + try: + subprocess.run(cmd, capture_output=True, check=True, stdin=None) + logger.debug(f"updated: '{podcast_file}'") + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + + +def get_podcast_name_from_dir(podcast_file): + podcast_dir = os.path.dirname(podcast_file) + if podcast_dir.startswith("/"): + # for now lets just do absolute dirs for names + podcast_name = os.path.basename(podcast_dir) + logger.debug(f"podcast name: {podcast_name}") + return podcast_name + else: + return None + + +def eyeD3_write_album_tag(podcast_file, podcast_name): + # "album" is the name of the podcast + + cmd = ["eyeD3", "--album", podcast_name, podcast_file] + try: + subprocess.run(cmd, capture_output=True, check=True, stdin=None) + except subprocess.CalledProcessError as e: + logger.error(f"{cmd[0]} exited with returncode {e.returncode} \n{e.stderr.decode()}") + raise SystemExit(e.returncode) + + +def parse_iso_date(date_str): + try: + dt = dateutil.parser.parse(date_str) + return dt.date().isoformat() + except (ParserError, TypeError) as e: + logger.warning(f"invalid date string: '{date_str}'") + + +def parse_TDAT_tag(tag_tdat): + try: + iso_date_str = tag_tdat.split(' ')[0] + return parse_iso_date(iso_date_str) + except (AttributeError, IndexError) as e: + logger.debug(f"invalid 'TDAT' tag: '{tag_tdat}'") + return None + + +def get_iso_date_in_file(file_path): + tags = ffprobe_get_tags(file_path) + + tag_TDAT = tags.get("TDAT") + tag_date = tags.get("date") + + parsed_TDAT = parse_TDAT_tag(tag_TDAT) + parsed_date = parse_iso_date(tag_date) + + if parsed_TDAT is None and parsed_date is None: + logger.error(f"no valid date found in '{file_path}' - TDAT: '{tag_TDAT}', date: '{tag_date}'") + raise SystemExit(3) + + else: + logger.debug(f"TDAT: '{parsed_TDAT}' ('{tag_TDAT}'), date: '{parsed_date}' ('{tag_date}')") + logger.debug(f"date: {parsed_date}") + + if parsed_TDAT != parsed_date: + logger.debug(f"dates in 'TDAT' ({parsed_TDAT}) and 'date' ({parsed_date}) differ!") + + if parsed_date is not None: + return parsed_date + else: + return parsed_TDAT + + +def file_dates_are_ok(file_path): + tags = ffprobe_get_tags(file_path) + tag_date = tags.get("date") + try: + dt = datetime.fromisoformat(tag_date) + ts = time.mktime(dt.timetuple()) + os.stat(file_path).st_mtime == ts + except ValueError: + return False + +def set_utime(file_path, iso_date_str): + dt = dateutil.parser.parse(iso_date_str) + ts = time.mktime(dt.timetuple()) + # shutil.move(file_path, f"{file_path}.new") + # shutil.move(f"{file_path}.new", file_path) + os.utime(file_path, (ts, ts)) + try: + os.utime(os.path.dirname(file_path), (ts, ts)) + except FileNotFoundError: + pass + return dt + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("podcast_file") + parser.add_argument("--out-file", default="/tmp/out-{os.getpid()}.mp3") + parser.add_argument("--debug", action="store_true") + parser.add_argument("--quiet", action="store_true") + parser.add_argument("--ffmpeg", action="store_true") + parser.add_argument("--mtime", action="store_true", help="only set mtime, no mp3 metadata") + parser.add_argument("--fix-album-tag", action="store_true", help="write album tag (podcast name)") + parser.add_argument("--podcast-name") + args = parser.parse_args() + + if not args.debug: + logger.remove() + + if args.quiet: + logger.add(sys.stderr, level="ERROR") + else: + logger.add(sys.stderr, level="INFO") + + return args + + +def main(): + args = parse_args() + logger.debug(f"checking: '{os.path.basename(args.podcast_file)}'") + + if args.fix_album_tag: + podcast_name = get_podcast_name_from_dir(args.podcast_file) + if podcast_name is not None: + eyeD3_write_album_tag(args.podcast_file, podcast_name) + logger.info(f"set album tag to '{podcast_name}' for '{args.podcast_file}'") + + + date = get_iso_date_in_file(args.podcast_file) + + if args.mtime: + dt = set_utime(args.podcast_filen, date) + logger.info(f"set mtime for '{os.path.basename(args.podcast_file)}' to '{dt.isoformat()}' according to mp3 metadata") + + elif file_dates_are_ok(args.podcast_file): + logger.info(f"metadata date and filesystem utimes ar ok for {args.podcast_file}', did not modify file") + else: + if args.ffmpeg: + ffmpeg_write_date_tag(args.podcast_file, args.out_file, date) + else: + eyeD3_write_date_tag(args.podcast_file, date) + + set_utime(args.podcast_file, date) + logger.success(f"updated date in '{args.podcast_file}' as {date}") + + + +if __name__ == "__main__": + main() diff --git a/roles/audiobookshelf/files/releasedate.sh b/roles/audiobookshelf/files/releasedate.sh new file mode 100755 index 0000000..cffa7cb --- /dev/null +++ b/roles/audiobookshelf/files/releasedate.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +set -e + +orig_file="$1" +release_date="$2" + +if [[ "$orig_file" == "" ]]; then + echo "missing parameter" + exit 1 +fi + +if [[ "$release_date" == "" ]]; then + echo "missing parameter" + exit 1 +fi + +if [[ "$3" == "" ]]; then + prefix="" +else + prefix="${3}-" +fi + +orig_name=$(basename "$1") +orig_dir=$(dirname "$1") +cover_path="${orig_dir}/cover.jpg" + +ffmpeg_file="ffmpeg-${prefix}${orig_name}" +eyed3_file="eyeD3-${prefix}${orig_name}" +abs_podcast_dir="/deadspace/audiobookshelf/podcasts/00-test-metadata" + +rm "${ffmpeg_file}" &> /dev/null || true +rm "no-date-tag-${ffmpeg_file}" &> /dev/null || true +rm "releasedate-${ffmpeg_file}" &> /dev/null || true +rm "release_date-${ffmpeg_file}" &> /dev/null || true +rm "${eyed3_file}" &> /dev/null || true + +rm -vf "${abs_podcast_dir}/${ffmpeg_file}" +rm -vf "${abs_podcast_dir}/${eyed3_file}" +rm -vf "${abs_podcast_dir}/metadata.abs" +rm -vf "${abs_podcast_dir}/cover.jpg" +ls -l "${abs_podcast_dir}/" + +cp "${orig_file}" "${eyed3_file}" + + +echo +set -x +eyeD3 --add-image "${cover_path}:FRONT_COVER" --release-date "${release_date}" "${eyed3_file}" > /dev/null + +#ffmpeg -i "${orig_file}" -c copy -metadata date="${release_date}" -metadata releasedate="${release_date}" "${ffmpeg_file}" &> /dev/null +ffmpeg -i "${orig_file}" -c copy -metadata date="${release_date}" "${ffmpeg_file}" &> /dev/null +#ffmpeg -i "${orig_file}" -c copy -metadata releasedate="${release_date}" "releasedate-${ffmpeg_file}" &> /dev/null +#ffmpeg -i "${orig_file}" -c copy -metadata release_date="${release_date}" "release_date-${ffmpeg_file}" &> /dev/null +set +x + +echo +echo "--------" +echo + +echo "file: ${orig_name}" +echo "original as downloaded by abs" + +echo +echo "> ffprobe ${orig_file}" +ffprobe "${orig_file}" 2>&1 | grep date +echo +echo "> eyeD3 ${orig_file}" +eyeD3 "${orig_file}" 2>&1 | grep date +echo + + +echo +echo "--------" +echo + +echo "file: ${eyed3_file}" +echo "eyeD3 with '--release-date ${release_date}'" + +echo +echo "> ffprobe ${eyed3_file}" +ffprobe "${eyed3_file}" 2>&1 | egrep "image|date|COVER" +echo +echo "> eyeD3 ${eyed3_file}" +eyeD3 "${eyed3_file}" 2>&1 | egrep "image|date|COVER" +echo + +echo +echo "--------" +echo + +echo "file: ${ffmpeg_file}" +#echo "ffmpeg with ONLY 'date=${release_date}'" +echo "ffmpeg with BOTH 'date=${release_date}' and 'releasedate=${release_date}' set to ISO date strings" + +echo +echo "> ffprobe ${ffmpeg_file}" +ffprobe "${ffmpeg_file}" 2>&1 | grep date +echo +echo "> eyeD3 ${ffmpeg_file}" +eyeD3 "${ffmpeg_file}" 2>&1 | grep date +echo + +echo +echo "--------" +echo + + +# echo +# echo "--------" +# echo +# echo "file: releasedate-${ffmpeg_file}" +# echo "ffmpeg with ONLY 'releasedate=${release_date}'" + +# echo +# echo "> ffprobe releasedate-${ffmpeg_file}" +# ffprobe "releasedate-${ffmpeg_file}" 2>&1 | grep date +# echo +# echo "> eyeD3 releasedate-${ffmpeg_file}" +# eyeD3 "releasedate-${ffmpeg_file}" 2>&1 | grep date +# echo + +# echo +# echo "--------" +# echo + +# echo "file: release_date-${ffmpeg_file}" +# echo "ffmpeg with ONLY 'release_date=${release_date}'" + +# echo +# echo "> ffprobe release_date-${ffmpeg_file}" +# ffprobe "release_date-${ffmpeg_file}" 2>&1 | grep date +# echo +# echo "> eyeD3 release_date-${ffmpeg_file}" +# eyeD3 "release_date-${ffmpeg_file}" 2>&1 | grep date +# echo + + + +chgrp media "${eyed3_file}" +chgrp media "${ffmpeg_file}" + +# chgrp media "releasedate-${ffmpeg_file}" +# chgrp media "release_date-${ffmpeg_file}" + + + +set -x +mv "${eyed3_file}" "${abs_podcast_dir}/" +mv "${ffmpeg_file}" "${abs_podcast_dir}/" + +#mv "releasedate-${ffmpeg_file}" "${abs_podcast_dir}/" +#mv "release_date-${ffmpeg_file}" "${abs_podcast_dir}/" +set +x + +echo +echo "> ls '${abs_podcast_dir}/'" +ls -1 "${abs_podcast_dir}/" +echo +echo +echo "done" + + +# curl -si 'https://audio.sudo.is/api/rescan' -X PUT \ +# -H 'Accept: application/json, text/plain, */*' \ +# -H 'Cookie: authelia_session=W^lyficYWGLOKCevu9qzsTsrFF4i!hA0' \ +# -H 'Content-Length: 0' \ +# -H 'TE: trailers' | head -n 1 diff --git a/roles/audiobookshelf/tasks/audiobookshelf.yml b/roles/audiobookshelf/tasks/audiobookshelf.yml index c66e5f1..0889f83 100644 --- a/roles/audiobookshelf/tasks/audiobookshelf.yml +++ b/roles/audiobookshelf/tasks/audiobookshelf.yml @@ -8,6 +8,7 @@ group: "{{ audiobookshelf_group.gid }}" tags: - audiobookshelf-dirs + - abs-dirs loop_control: label: "{{ item.name }}" with_items: @@ -43,6 +44,7 @@ tags: - nginx - audiobookshelf-nginx + - abs-nginx notify: reload nginx - name: start audiobookshelf container @@ -79,4 +81,18 @@ target: /metadata tags: - audiobookshelf-container + - abs-container - docker-containers + +- name: copy abs scripts + copy: + src: "{{ item }}" + dest: "/usr/local/bin/{{ item }}" + owner: "{{ audiobookshelf_user.uid }}" + group: "{{ audiobookshelf_group.gid }}" + mode: 0755 + with_items: + - fix-podcast-date.py + tags: + - abs-scripts + - audiobookshelf-scripts diff --git a/roles/backup/files/restic-backups.py b/roles/backup/files/restic-backups.py index 89b7b19..6313aff 100755 --- a/roles/backup/files/restic-backups.py +++ b/roles/backup/files/restic-backups.py @@ -105,7 +105,19 @@ def run_restic(repo_url, restic_args, dry_run, non_interactive): return # .run(env={}) is possible (but then child doesnt inherit parent env) - return subprocess.run(restic_cmd, check=True) + try: + ps = subprocess.run(restic_cmd, check=True, capture_output=False) + # if ps.stdout is not None: + # summary = ps.stdout.decode().split('\n')[-7:] + # if backup: + # logger.success(f"{summary[2].strip()} ({summary[4]})") + except subprocess.CalledProcessError as e: + cmd = " ".join(e.cmd) + # e.stdout, e.strderr + if e.stderr is not None: + logger.error(e.stderr.decode()) + logger.error(f"command '{cmd}' returned non-zero exit status {e.returncode}.") + raise SystemExit(e.returncode) def find_nobackup(config): # very interesting restic option: diff --git a/roles/hass/files/packages/amp.yaml b/roles/hass/files/packages/amp.yaml new file mode 100644 index 0000000..757bd3f --- /dev/null +++ b/roles/hass/files/packages/amp.yaml @@ -0,0 +1,117 @@ +input_boolean: + amp_muted: + name: amp_muted + +input_text: + nad_c370_sonos: + name: NAD C370 input for Sonos + initial: disc + pattern: disc + + nad_c370_apple_tv: + name: NAD C370 input for Apple TV + initial: cd + pattern: cd + + nad_c370_tv: + name: NAD C370 input for TV + initial: cd + pattern: cd + +input_select: + amp_inputs: + name: amp_inputs + options: + - video + - disc + - cd + - aux + - tape1 + - tape2 + +script: + amp_on: + mode: single + sequence: + - service: switch.turn_on + target: + entity_id: switch.nad_c370 + + amp_off: + mode: single + sequence: + - service: switch.turn_off + target: + entity_id: switch.nad_c370 + + amp_mute_toggle: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + command: mute + device: nad_c370 + + amp_remote_select_input: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + command: input_{{ source }} + device: nad_c370 + + amp_select_input2: + mode: single + sequence: + - service: script.amp_remote_select_input + data: + soure: >- + {% set input_device = states('input_select.media_center_input') %} + {{ states('input_text.nad_c370' ~ input_device ) }} + amp_select_input: + mode: single + sequence: + - if: + - condition: state + entity_id: input_select.media_center_inputs + state: "tv" + then: + - service: input_boolean.amp_remote_select_input + data: + amp_input_source: cd + - if: + - condition: state + entity_id: input_select.media_center_inputs + state: "apple_tv" + then: + - service: input_boolean.amp_remtote_select_input + data: + amp_input_source: cd + - if: + - condition: state + entity_id: input_select.media_center_inputs + state: "sonos" + then: + - service: input_boolean.amp_remtote_select_input + data: + amp_input_source: disc + + +media_player: + - platform: universal + name: NAD C370 + universal_id: nad_c370 + children: [] + turn_on: + service: script.amp_on + turn_off: + service: script.amp_off + + select_source: + service: script.amp_select_input + + attributes: state_attr('input_select.amp_inputs', 'options') | source_list diff --git a/roles/hass/files/packages/fans.yaml b/roles/hass/files/packages/fans.yaml new file mode 100644 index 0000000..b873d37 --- /dev/null +++ b/roles/hass/files/packages/fans.yaml @@ -0,0 +1,132 @@ +input_boolean: + standing_fan_1_state_power: + name: standing_fan_1_state_power + icon: mdi:fan + # no 'initial' value: restores to previous state on restart + #initial: false + +input_select: + standing_fan_1_preset_mode: + name: standing_fan_1_preset_mode + options: + - silent_night + - turbo + - normal + # no 'initial' value: restores to previous state on restart + #initial: normal + icon: mdi:fan + +script: + standing_fan_1_power_toggle: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: standing_fan_1 + command: power_toggle + + standing_fan_1_on: + mode: single + sequence: + - if: + - condition: state + entity_id: input_boolean.standing_fan_1_state_power + state: "off" + then: + - service: script.standing_fan_1_power_toggle + - service: input_boolean.turn_on + data: {} + target: + entity_id: input_boolean.standing_fan_1_state_power + + standing_fan_1_off: + mode: single + sequence: + - if: + - condition: state + entity_id: input_boolean.standing_fan_1_state_power + state: "on" + then: + - service: script.standing_fan_1_power_toggle + - service: input_boolean.turn_off + data: {} + target: + entity_id: input_boolean.standing_fan_1_state_power + + standing_fan_1_mode_silent_night: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: standing_fan_1 + command: silent_night + + standing_fan_1_mode_turbo: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: standing_fan_1 + command: turbo + + standing_fan_1_mode_normal: + mode: single + sequence: + # send the command for the active (if it is active) mode to turn it off (or back to 'normal') + - service: script.standing_fan_1_mode_{{ states('input_select.standing_fan_1_preset_mode') }} + + standing_fan_1_reset_power_state: + mode: single + sequence: + - service: input_boolean.toggle + target: + entity_id: input_boolean.standing_fan_1_state_power + data: {} + + standing_fan_1_set_preset_mode: + mode: single + sequence: + - service: script.standing_fan_1_mode_{{ preset_mode }} + - service: input_select.select_option + target: + entity_id: input_select.standing_fan_1_preset_mode + data: + option: "{{ preset_mode }}" + +# automation: +# - alias: standing_fan_1_hass_shutdown +# mode: single +# trigger: +# - platform: homeassistant +# event: shutdown +# condition: [] +# action: +# - service: script.standing_fan_1_off + + +fan: + - platform: template + fans: + standing_fan_1: + unique_id: standing_fan_1 + friendly_name: "Standing fan 1" + value_template: "{{ states('input_boolean.standing_fan_1_state_power') }}" + preset_mode_template: "{{ states('input_select.standing_fan_1_preset_mode') }}" + turn_on: + service: script.standing_fan_1_on + turn_off: + service: script.standing_fan_1_off + set_preset_mode: + service: script.standing_fan_1_set_preset_mode + data: + preset_mode: "{{ preset_mode }}" + preset_modes: + - silent_night + - turbo + - normal diff --git a/roles/hass/files/packages/grow_lights.yaml b/roles/hass/files/packages/grow_lights.yaml new file mode 100644 index 0000000..23a14bc --- /dev/null +++ b/roles/hass/files/packages/grow_lights.yaml @@ -0,0 +1,209 @@ +input_boolean: + grow_lights_1_state: + name: grow_lights_1_state + icon: mdi:sprout + +input_number: + grow_lights_1_brightness: + name: grow_lights_1_brightness + min: 1 + max: 8 + step: 1.0 + initial: 8 + mode: slider + icon: mdi:sprout + + +script: + grow_lights_1_on: + mode: single + sequence: + - service: input_boolean.turn_on + data: {} + target: + entity_id: input_boolean.grow_lights_1_state + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: grow_lights_1 + command: power_on + num_repeats: 3 + delay_secs: 0.1 + hold_secs: 0.1 + - service: script.grow_lights_1_brightness_max + + grow_lights_1_off: + mode: single + sequence: + - service: input_boolean.turn_off + data: {} + target: + entity_id: input_boolean.grow_lights_1_state + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: grow_lights_1 + command: power_off + num_repeats: 3 + delay_secs: 0.1 + hold_secs: 0.1 + + grow_lights_1_brightness_up: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: grow_lights_1 + command: brightness_up + delay_secs: 0.1 + hold_secs: 0.1 + num_repeats: "{{ num_repeats | default(1) | int | abs }}" + + grow_lights_1_brightness_down: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: grow_lights_1 + command: brightness_down + delay_secs: 0.1 + hold_secs: 0.1 + num_repeats: "{{ num_repeats | default(1) | int | abs }}" + + grow_lights_1_brightness_min: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: grow_lights_1 + command: brightness_down + hold_secs: 0.1 + num_repeats: "{{ 8*2 | int }}" + - service: input_number.set_value + target: + entity_id: input_number.grow_lights_1_brightness + data: + value: "{{ 1 | int }}" + + grow_lights_1_brightness_max: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: grow_lights_1 + command: brightness_up + hold_secs: 0.1 + num_repeats: "{{ 8*2 | int }}" + - service: input_number.set_value + target: + entity_id: input_number.grow_lights_1_brightness + data: + value: "{{ 8 | int }}" + + grow_lights_1_brightness_set: + mode: single + sequence: + - service: script.grow_lights_1_brightness_min + - service: script.grow_lights_1_brightness_up + data: + num_repeats: "{{ brightness_steps | int }}" + - service: input_number.set_value + target: + entity_id: input_number.grow_lights_1_brightness + data: + value: "{{ brightness_steps | int }}" + +automation: + - alias: grow_lights_1_state + trigger: + - platform: time_pattern + minutes: /15 + condition: [] + action: + - service: >- + script.grow_lights_1_{{ states('binary_sensor.grow_lights_1_schedule') }} + data: {} + + - alias: grow_lights_1_hass_start + trigger: + - platform: homeassistant + event: start + condition: [] + action: + - service: >- + light.turn_{{ states('binary_sensor.grow_lights_1_schedule') }} + target: + entity_id: light.grow_lights_1 + data: {} + + - alias: grow_lights_1_schedule + trigger: + - platform: state + entity_id: binary_sensor.grow_lights_1_schedule + to: "on" + - platform: state + entity_id: binary_sensor.grow_lights_1_schedule + to: "off" + condition: [] + action: + - service: >- + light.turn_{{ trigger.to_state.state }} + target: + entity_id: light.grow_lights_1 + data: {} + + +binary_sensor: + - platform: tod + unique_id: grow_lights_1_schedule + name: grow_lights_1_schedule + after: "09:00" + before: "19:00" + + +light: + - platform: template + lights: + grow_lights_1: + unique_id: grow_lights_1 + friendly_name: "Grow lights 1" + value_template: "{{ states('input_boolean.grow_lights_1_state') }}" + level_template: >- + {% set brightness_max_8 = states('input_number.grow_lights_1_brightness')|int %} + {% set brightness_max_256 = brightness_max_8 * 32 %} + {% set brightness_max_255 = min(255, brightness_max_256) %} + {{ max(1, brightness_max_255) }} + icon_template: >- + {% if is_state(this.entity_id, 'on') %} + mdi:sprout + {% else %} + mdi:sprout-outline + {% endif %} + turn_on: + service: script.grow_lights_1_on + turn_off: + service: script.grow_lights_1_off + set_level: + service: >- + {% if brightness > 200 %} + script.grow_lights_1_brightness_max + {% elif brightness < 100 %} + script.grow_lights_1_brightness_min + {% else %} + script.grow_lights_1_brightness_set + {% endif %} + data: + brightness: "{{ brightness | int }}" + brightness_steps: >- + {% set steps = brightness // 32 | default(8) | round | int %} + {{ max(1, steps) | int }} diff --git a/roles/hass/files/packages/media_center.yaml b/roles/hass/files/packages/media_center.yaml new file mode 100644 index 0000000..f7936f5 --- /dev/null +++ b/roles/hass/files/packages/media_center.yaml @@ -0,0 +1,123 @@ +input_boolean: + tv_assumed_state: + name: tv_assumed_power_state + icon: mdi:television + + +input_select: + media_center_inputs: + name: media_center_inputs + options: + - sonos + - apple_tv + - tv + +script: + tv_power_toggle: + mode: single + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + command: power_toggle + device: tv + + tv_on: + mode: single + sequence: + - if: + - condition: state + entity_id: input_boolean.tv_assumed_power_state + state: "off" + then: + - service: script.tv_power_toggle + - service: input_boolean.turn_on + target: + entity_id: input_boolean.tv_assumed_power_state + + tv_off: + mode: single + sequence: + - if: + - condition: state + entity_id: input_boolean.tv_assumed_power_state + state: "on" + then: + - service: script.tv_power_toggle + - service: input_boolean.turn_off + target: + entity_id: input_boolean.tv_assumed_power_state + + apple_tv_on: + mode: single + sequence: + # - service: homeassistant.reload_config_entry + # target: + # entity_id: + # #- media_player.apple_tv + # - remote.apple_tv + - service: remote.send_command + data: + command: + - wakeup + - select + delay_secs: 1 + target: + entity_id: media_player.apple_tv + + apple_tv_off: + mode: single + sequence: + - service: remote.send_command + data: + command: + - home_hold + - select + delay_secs: 1 + target: + entity_id: media_player.apple_tv + # - service: homeassistant.reload_config_entry + # target: + # entity_id: + # #- media_player.apple_tv + # - remote.apple_tv + + + media_center_select_input: + mode: single + sequence: + - service: input_select.select_option + target: + entity_id: input_boolean.media_center_inputs + data: + option: "{{ media_center_input }}" + - service: script.amp_select_input + # another one for tv? + + + media_center_on: + mode: single + sequence: + - service: script.amp_on + - service: script.tv_on + - service: script.apple_tv_on + + media_center_off: + mode: single + sequence: + - service: script.amp_off + - service: script.tv_off + - service: script.apple_tv_off + - service: media_player.turn_off + target: + entity_id: media_player.owntone_output_living_room + +media_player: + - platform: universal + name: Media Center + universal_id: media_center + children: + - media_player.apple_tv + - media_player.living_room_audio + - media_player.owntone_server diff --git a/roles/hass/files/packages/usb_led_strings.yaml b/roles/hass/files/packages/usb_led_strings.yaml new file mode 100644 index 0000000..a695c5c --- /dev/null +++ b/roles/hass/files/packages/usb_led_strings.yaml @@ -0,0 +1,88 @@ +input_boolean: + usb_led_string_1_state: + name: usb_led_string_1_state + initial: false + +script: + usb_led_string_1_on: + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: usb_led_string_1 + command: + - power_on + - steady_on + - service: input_boolean.turn_on + data: {} + target: + entity_id: input_boolean.usb_led_string_1_state + + usb_led_string_1_off: + sequence: + - service: remote.send_command + target: + entity_id: remote.broadlink + data: + device: usb_led_string_1 + command: + - power_off + - service: input_boolean.turn_off + data: {} + target: + entity_id: input_boolean.usb_led_string_1_state + +automation: + - alias: usb_led_string_1_state + trigger: + - platform: state + entity_id: input_boolean.usb_led_string_1_state + for: + hours: 0 + minutes: 5 + seconds: 0 + to: "on" + id: "on" + - platform: state + entity_id: input_boolean.usb_led_string_1_state + for: + hours: 0 + minutes: 5 + seconds: 0 + to: "off" + id: "off" + condition: [] + action: + - service: >- + script.usb_led_string_1_{{ trigger.id }} + data: {} + + - alias: usb_led_string_1_hass_start + trigger: + - platform: homeassistant + event: start + condition: [] + action: + - service: >- + {% if is_state('sun.sun', 'below_horizon') %} + light.turn_on + {% else %} + light.turn_off + {% endif %} + target: + entity_id: light.usb_led_string_1 + data: {} + +light: + - platform: template + lights: + usb_led_string_1: + unique_id: usb_led_string_1 + friendly_name: "USB LED string 1" + value_template: "{{ states('input_boolean.usb_led_string_1_state') }}" + icon_template: mdi:led-strip-variant + turn_on: + service: script.usb_led_string_1_on + turn_off: + service: script.usb_led_string_1_off diff --git a/roles/hass/tasks/hass.yml b/roles/hass/tasks/hass.yml index d0f93b9..bb0b098 100644 --- a/roles/hass/tasks/hass.yml +++ b/roles/hass/tasks/hass.yml @@ -32,6 +32,7 @@ mode: "0775" - name: home-assistant - name: home-assistant/config + - name: home-assistant/config/packages - name: home-assistant/config/python_scripts - name: home-assistant/config/bvg - name: home-assistant/.config # might not be needed, misread 'cache' as 'config' @@ -68,7 +69,6 @@ - hass-git - hass-git-clone - - name: home assistant config files template: src: "{{ item }}.j2" @@ -81,20 +81,36 @@ - secrets.yaml - configuration.yaml - templates.yaml - - climate.yaml - automations-ansible-managed.yaml - scripts-ansible-managed.yaml - blink1.yaml + # - packages/climate.yaml + - packages/toothbrush.yaml tags: - hass-config -- name: copy dashboards +- name: copy config files + copy: + src: "{{ item }}" + dest: "{{ systemuserlist.hass.home }}/home-assistant/config/{{ item }}" + mode: 0644 + owner: "{{ systemuserlist.hass.uid }}" + group: "{{ systemuserlist.hass.gid }}" + notify: restart hass container + with_items: + - packages/usb_led_strings.yaml + - packages/grow_lights.yaml + - packages/fans.yaml + tags: + - hass-config + +- name: copy dashboard config files copy: src: "private/hass/{{ item }}" dest: "{{ systemuserlist.hass.home }}/home-assistant/config/{{ item }}" - mode: 0755 - owner: hass - group: hass + mode: 0644 + owner: "{{ systemuserlist.hass.uid }}" + group: "{{ systemuserlist.hass.gid }}" notify: restart hass container with_items: - mini.yaml @@ -200,6 +216,7 @@ - mplayer - bluez - bluetooth + - espeak-ng # for glados-tts - fapg - podget - sqlite3 @@ -281,8 +298,8 @@ - name: start home-assistant container docker_container: name: hass - image: ghcr.io/home-assistant/home-assistant:stable - #image: git.sudo.is/ben/hass:latest + #image: ghcr.io/home-assistant/home-assistant:stable + image: git.sudo.is/ben/hass:latest detach: true pull: true restart_policy: "unless-stopped" diff --git a/roles/hass/templates/automations-ansible-managed.yaml.j2 b/roles/hass/templates/automations-ansible-managed.yaml.j2 index 352282a..3186afd 100644 --- a/roles/hass/templates/automations-ansible-managed.yaml.j2 +++ b/roles/hass/templates/automations-ansible-managed.yaml.j2 @@ -23,6 +23,7 @@ title: 'device not reporting' message: 'stopped reporting: {%raw%}{{ trigger.id }}{%endraw%}' +{% if hass_light_switches | length > 0 -%} - alias: refresh_light_switches_state description: the tkbhome switches dont automatically report their state trigger: @@ -39,6 +40,7 @@ - {{ item.entity_id }} {% endif -%} {% endfor %} +{% endif -%} {% for item in hass_light_switches -%} {% set domain = item.entity_id.split('.')[0] %} diff --git a/roles/hass/templates/climate.yaml.j2 b/roles/hass/templates/climate.yaml.j2 index 897c2b3..7d13206 100644 --- a/roles/hass/templates/climate.yaml.j2 +++ b/roles/hass/templates/climate.yaml.j2 @@ -12,7 +12,6 @@ current_temperature_template: "{{ states('input_number.heating_setpoint_test') }}" hvac_mode_template: "{{ states('input_select.heating_mode_test') }}" current_humidity_template: 0.0 - swing_mode_template: false availability_template: true set_temperature: diff --git a/roles/hass/templates/configuration.yaml.j2 b/roles/hass/templates/configuration.yaml.j2 index 70be9ea..15006e4 100644 --- a/roles/hass/templates/configuration.yaml.j2 +++ b/roles/hass/templates/configuration.yaml.j2 @@ -62,7 +62,7 @@ automation ansible: !include automations-ansible-managed.yaml script: !include scripts.yaml scene: !include scenes.yaml template: !include templates.yaml -climate: !include climate.yaml +#climate: !include climate.yaml # Text to speech tts: @@ -89,6 +89,8 @@ http: - 127.0.0.1 use_x_forwarded_for: true +api: + frontend: themes: !include_dir_merge_named themes @@ -158,6 +160,7 @@ homeassistant: customize: zone.home: friendly_name: S21 + packages: !include_dir_named packages lovelace: mode: storage @@ -228,6 +231,24 @@ sensor: file_path: "/config/bvg/" {% endfor %} + - platform: worldclock + time_zone: UTC + name: UTC + - platform: worldclock + time_zone: Atlantic/Reykjavik + name: Iceland + - platform: worldclock + time_zone: Europe/Berlin + name: Berlin + - platform: worldclock + time_zone: America/New_York + name: Boston + - platform: worldclock + time_zone: America/Chicago + name: Austin + + + - platform: waqi token: !secret waqi_token locations: @@ -306,24 +327,33 @@ input_text: device_tracker: - platform: bluetooth_le_tracker + # device tracker will only look for the following global settings + # under the configuration of the first configured platform interval_seconds: 12 - track_new_devices: false - track_battery: false - consider_home: 150 - new_device_defaults: - track_new_devices: false + consider_home: 120 + # setting this to 'false' still results in new devices being added + # to known_devices.yaml when they are discovered, but they wont be + # tracked unless 'track' is set to 'true' for a device there (edit + # the file to track a discovered device). + track_new_devices: true + #new_device_defaults: + # track_new_devices: false + - platform: bluetooth_tracker - request_rssi: false - interval_seconds: 12 - track_new_devices: false - consider_home: 150 - new_device_defaults: - track_new_devices: false + request_rssi: true + track_new_devices: true - platform: ping + # these are probably not used, because they are "global settings" + # and only the first values from the first platform (bluetooth_le_tracker) + # are used according to the docs + interval_seconds: 30 + track_new_devices: true + # but consider_hoem can be overridden + consider_home: 120 hosts: {% for target in hass_ping -%} {% if target.device_tracker|default(true) -%} - {{ target.name }}: {{ target.host }} + ping_{{ target.name }}: {{ target.host }} {% endif -%} {% endfor %} @@ -402,24 +432,19 @@ light: blink1: !include blink1.yaml {% endif %} -# enable 'wake_on_lan' for 'samsungtv' -wake_on_lan: +{# + # feedreader: + # urls: + # {% for item in hass_feedreader -%} + # - "{{ item.url | trim }}" + # {% endfor %} + #} -samsungtv: - - host: {{ hass_wifi_blackbox.tv_ip }} - name: The TV - turn_on_action: - - service: wake_on_lan.send_magic_packet - data: - mac: "{{ hass_wifi_blackbox.tv_mac }}" +logger: + default: warning + logs: + pyatv: debug + homeassistant.components.apple_tv: debug -feedreader: - urls: - {% for item in hass_feedreader -%} - - "{{ item.url | trim }}" - {% endfor %} - -{# logger: - # logs: - # pyatv: debug - # homeassistant.components.apple_tv: debug #} +# enable SVT play +svt_play: diff --git a/roles/hass/templates/git-hass-config.sh.j2 b/roles/hass/templates/git-hass-config.sh.j2 index 9d8b408..31c8215 100644 --- a/roles/hass/templates/git-hass-config.sh.j2 +++ b/roles/hass/templates/git-hass-config.sh.j2 @@ -25,13 +25,26 @@ fi mkdir -p ${PATH_REPO}/config/ mkdir -p ${PATH_REPO}/config/.storage -{% for item in hass_config_repo_files -%} -cp ${PATH_HASS}/config/{{ item }} ${PATH_REPO}/config/{{ item }} +{% for item in hass_config_repo_cp -%} +{% if item.dir|default(false) %}{% set cp_r = "r" -%} +{% else %}{% set cp_r = "" -%} +{% endif -%} +{% if item.src.endswith("*") -%} +{% if item.src.count("/") > 0 -%} +{% set dest = item.src.split("/")[:-1] | join("/") + "/" -%} +{% else -%} +{% set dest = "" -%} +{% endif -%} +{% else -%} +{% set dest = item.src %} +{% endif %} +cp -a{{ cp_r }} ${PATH_HASS}/config/{{ item.src }} ${PATH_REPO}/config/{{ dest }} {% endfor %} + if test -n "$(git status --porcelain)" ; then - {% for item in hass_config_repo_files -%} - git add config/{{ item }} > /dev/null + {% for item in hass_config_repo_cp -%} + git add config/{{ item.src }} > /dev/null {% endfor %} git commit -m "config updated" > /dev/null diff --git a/roles/hass/templates/hass-cron.j2 b/roles/hass/templates/hass-cron.j2 index 2b08717..33c5241 100644 --- a/roles/hass/templates/hass-cron.j2 +++ b/roles/hass/templates/hass-cron.j2 @@ -5,7 +5,4 @@ # sync hass config to {{ hass_config_repo }} */15 * * * * {{ systemuserlist.hass.username }} /usr/local/bin/git-hass-config.sh - - - # diff --git a/roles/hass/templates/packages/toothbrush.yaml.j2 b/roles/hass/templates/packages/toothbrush.yaml.j2 new file mode 100644 index 0000000..e2aaecd --- /dev/null +++ b/roles/hass/templates/packages/toothbrush.yaml.j2 @@ -0,0 +1,54 @@ +input_number: + toothbrushing_target_time: + name: toothbrushing_target_time + min: 10 + max: 600 + step: 1 + initial: {{ toothbrushing_target_time }} + icon: "mdi:toothbrush-electric" + mode: box + +template: + - sensor: + + {% for item in hass_toothbrushes -%} + + - name: "{{ item.entity_name }}" + unique_id: "{{ item.entity_name }}" + icon: "mdi:toothbrush-electric" + state_class: "measurement" + unit_of_measurement: "s" + {% raw -%} + state: >- + {% set target_time = states('input_number.toothbrushing_target_time') | int %} + {% set source_entity_id = this.attributes.source_entity_id | default("unknown") %} + {% if states(source_entity_id) not in ["unknown", "unavailable"] %} + {% set time = states(source_entity_id) | int %} + {% endif %} + {% set brush_time = time|default(0)|int %} + {{ max(0, target_time - brush_time) }} + {% endraw -%} + attributes: + source_entity_id: "{{ item.source_entity_id }}" + + {% endfor %} + + - binary_sensor: + + {% for item in hass_toothbrushes -%} + + - name: "{{ item.entity_name }}" + unique_id: "{{ item.entity_name }}" + icon: "mdi:toothbrush-electric" + delay_off: "00:00:10" + {% raw -%} + state: >- + {{ states("sensor." ~ this.name)|int == 0 }} + attributes: + hours_since_last_brush: >- + {% set time_since = now() - this.last_changed %} + {{ time_since.seconds // 60 // 60 }} + + {% endraw %} + + {% endfor %} diff --git a/roles/hass/templates/templates.yaml.j2 b/roles/hass/templates/templates.yaml.j2 index b4c7f0e..e41a0a3 100644 --- a/roles/hass/templates/templates.yaml.j2 +++ b/roles/hass/templates/templates.yaml.j2 @@ -1,3 +1,22 @@ +{# + # device attributes: + # - config_entries + # - connections + # - identifiers + # - manufacturer + # - model + # - name + # - sw_version + # - hw_version + # - entry_type + # - id + # - via_device_id + # - area_id + # - name_by_user + # - disabled_by + # - configuration_url + #} + - sensor: - name: "chance_of_rain" unit_of_measurement: "%" @@ -14,7 +33,7 @@ {% if 'status' in radiator %} - name: "radiator_{{ radiator.name }}_last_updated" - unit_of_measurement: "minutes" + unit_of_measurement: "min" icon: "mdi:update" device_class: duration state: >- @@ -30,6 +49,22 @@ {% for linux_tracker in hass_linux_presence_trackers -%} {% endfor %} + + + {# {% if is_state("sensor." ~ this.name ~ "_state", "unavailable") %} + # {{ false | bool }} + # {% elif is_state("sensor." ~ this.name ~ "_state", "unavailable") %} + # {{ false | bool }} + # {% elif is_state("sensor." ~ this.name ~ "_time", "unknown") %} + # {{ false | bool }} + # {% else %} + # {{ true | bool }} + # + # friendly_name: >- + # {{ device_attr("sensor." ~ this.name ~ "_state", "name_by_user") }} + # + #} + - binary_sensor: {% if blink1_enabled -%} - name: "blink1_on" @@ -51,15 +86,6 @@ {{ max_radiator_temp >= target_temp }} {% endraw %} - - name: s21_anyone_home - icon: "mdi:home-account" - attributes: - friendly_name: "Anyone home" - state: >- - {% raw -%} - {{ state_attr("zone.home", "persons") | default([]) | length > 0 }} - {% endraw %} - - name: doorbell_buzzer state: >- {% raw %} {{ is_state("switch.doorbell_buzzer", "on") }} {% endraw +%} @@ -88,6 +114,7 @@ {% endif %} {% endraw %} + {% for linux_tracker in hass_linux_presence_trackers -%} - name: {{ linux_tracker.name }}_active icon: "mdi:laptop" diff --git a/roles/owntone/templates/owntone.conf.j2 b/roles/owntone/templates/owntone.conf.j2 index 390157a..3f6643c 100644 --- a/roles/owntone/templates/owntone.conf.j2 +++ b/roles/owntone/templates/owntone.conf.j2 @@ -224,12 +224,13 @@ audio { nickname = "Computer" # Type of the output (alsa, pulseaudio, dummy or disabled) - type = "disabled" + #type = "disabled" + type = "pulseaudio" # For pulseaudio output, an optional server hostname or IP can be # specified (e.g. "localhost"). If not set, connection is made via local # socket. - #server = "" + server = "localhost" # Audio PCM device name for local audio output - ALSA only #card = "default" @@ -379,7 +380,7 @@ spotify { # Your Spotify playlists will by default be put in a "Spotify" playlist # folder. If you would rather have them together with your other # playlists you can set this option to true. - base_playlist_disable = true + base_playlist_disable = false # Spotify playlists usually have many artist, and if you dont want # every artist to be listed when artist browsing in Remote, you can set