diff --git a/amelioration.md b/amelioration.md new file mode 100644 index 0000000..bf9c246 --- /dev/null +++ b/amelioration.md @@ -0,0 +1,16 @@ +analyse quels est le meilleur model de dev sonnet ou opus pour ce projet apres analyse du projet + +- [ ] ajout d'un nouveau laptop yoga14 qui me sert a regarder youtube connecté a ma tablette via vacuum tube. je souhaites ajouter des fonction pilotable depuis home assistant via mqtt: +- shutdown, reboot, adresse ip, +- connection et etat bluetooth connection a thinkplus k3 pro +- reglage du niveau de son +- demarrage ou arret de vavuum tube , et passage en plein ecran +- mode veille, et veille prolongé +- command sudo apt update et upgrade -y +- lancement de la télé livebox tv via vlc ? +- telecommande g7bts + +fait un brainstorming general apres analyse du projet, puis propose moi l'implementation en ajoutant dans config le parametrage des entité que je souhaite suivre. verifie la faisabilité de ma demande, y a t il des amelioration utile a implementer en fonction de l'utilisation + +- [ ] script d'installation de l'app pilot via mon repo gitea : +https://gitea.maison43.duckdns.org/gilles/pilot avec une installation par defaut dans le dossier /home/gilles/pilot \ No newline at end of file diff --git a/config/config.example.yaml b/config/config.example.yaml index 44d20db..b203959 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -287,11 +287,34 @@ features: device_class: "" icon: "mdi:desktop-classic" state_class: "" + # Niveau sonore actuel (via wpctl, PipeWire) + volume_level: + enabled: true + discovery_enabled: true + interval_s: 30 + name: "Volume Level" + unique_id: "$hostname_volume_level" + unit: "%" + device_class: "" + icon: "mdi:volume-high" + state_class: "measurement" commands: enabled: true cooldown_s: 5 dry_run: true - allowlist: ["shutdown", "reboot", "sleep", "screen"] + allowlist: + - "shutdown" + - "reboot" + - "sleep" + - "hibernate" + - "screen" + - "volume" + - "system_update" + - "inhibit_sleep" + - "app_vacuum_tube" + - "app_livebox_tv" + - "bluetooth_k3pro" + - "bluetooth_g7bts" power_backend: linux: "linux_logind_polkit" # or linux_sudoers @@ -305,7 +328,40 @@ publish: heartbeat_s: 30 availability: true +# Applications pilotables depuis Home Assistant +# start_args: arguments passes au demarrage (ex: --fullscreen) +# process_check: motif pour pgrep/pkill (recherche dans le nom complet du processus) +apps: + - name: "vacuum_tube" + display_name: "VacuumTube" + enabled: true + start_cmd: "flatpak" + start_args: ["run", "rocks.shy.VacuumTube"] + process_check: "rocks.shy.VacuumTube" + - name: "livebox_tv" + display_name: "Livebox TV" + enabled: true + start_cmd: "vlc" + start_args: + - "--fullscreen" + - "--network-caching=1000" + - "/home/gilles/pilot/iptv/france_tv.m3u" # ou chemin local apres installation + process_check: "vlc" + channels_m3u: "/home/gilles/pilot/iptv/france_tv.m3u" # active le selecteur de chaine HA + +# Appareils Bluetooth a surveiller et controler +# mac: adresse MAC (obtenue via: bluetoothctl paired-devices) +# Prerequis: utilisateur dans le groupe 'bluetooth' +bluetooth: + enabled: true + devices: + - name: "k3pro" + mac: "F1:B7:7F:BC:7B:00" + display_name: "ThinkPlus K3 Pro" + - name: "g7bts" + mac: "AA:23:02:16:32:6F" + display_name: "Rii G7BTS" + paths: linux_config: "/etc/pilot/config.yaml" windows_config: "C:\\ProgramData\\Pilot\\config.yaml" -# Codex modified 2025-12-29_0224 diff --git a/config/france_tv.m3u b/config/france_tv.m3u new file mode 100644 index 0000000..ea5db29 --- /dev/null +++ b/config/france_tv.m3u @@ -0,0 +1,147 @@ +#EXTM3U x-tvg-url="https://iptv-org.github.io/epg/guides/fr.xml" +# Chaines francophones - compatible VLC +# Sources : iptv-org.github.io | Free-TV/IPTV +# Note : certaines chaines sont geo-bloquees (signalees [GEO]) +# BFM : necessite un User-Agent navigateur (inclus via #EXTVLCOPT) + +# ============================================================ +# TNT FRANCE +# ============================================================ + +#EXTINF:-1 tvg-id="France2.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/France_2_-_Logo_2018.svg/200px-France_2_-_Logo_2018.svg.png" group-title="TNT France",France 2 +http://69.64.57.208/france2/mono.m3u8 + +#EXTINF:-1 tvg-id="France5.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/France_5_logo_2002.svg/200px-France_5_logo_2002.svg.png" group-title="TNT France",France 5 +http://69.64.57.208/france5/mono.m3u8 + +#EXTINF:-1 tvg-id="arte.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Arte_Logo_2019.svg/200px-Arte_Logo_2019.svg.png" group-title="TNT France",Arte +https://artesimulcast.akamaized.net/hls/live/2031003/artelive_fr/index.m3u8 + +#EXTINF:-1 tvg-id="NRJ12.fr" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/NRJ_12_logo_2019.svg/200px-NRJ_12_logo_2019.svg.png" group-title="TNT France",NRJ 12 +https://nrj12.nrjaudio.fm/hls/live/2038374/nrj_12/master.m3u8 + +#EXTINF:-1 tvg-id="LEquipe.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/L%27Equipe_logo.svg/200px-L%27Equipe_logo.svg.png" group-title="TNT France",L'Equipe +https://dshn8inoshngm.cloudfront.net/v1/master/3722c60a815c199d9c0ef36c5b73da68a/LS-6666-57946/index.m3u8 + +# ============================================================ +# INFO FRANCE +# ============================================================ + +#EXTINF:-1 tvg-id="BFMTV.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/BFM_TV_logo.svg/200px-BFM_TV_logo.svg.png" group-title="Info France",BFM TV +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_TV/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMBusiness.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/BFM_Business_logo.svg/200px-BFM_Business_logo.svg.png" group-title="Info France",BFM Business +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_BUSINESS/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFM2.fr@HD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/BFM2_logo_2021.svg/200px-BFM2_logo_2021.svg.png" group-title="Info France",BFM 2 +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM2/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="Franceinfo.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/5/58/France_info.svg/200px-France_info.svg.png" group-title="Info France",Franceinfo TV +https://raw.githubusercontent.com/Sibprod/streams/main/ressources/dm/py/hls/franceinfotv.m3u8 + +#EXTINF:-1 tvg-id="PublicSenat2424.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Public_S%C3%A9nat_logo_2020.svg/200px-Public_S%C3%A9nat_logo_2020.svg.png" group-title="Info France",Public Sénat 24/24 +https://raw.githubusercontent.com/Sibprod/streams/main/ressources/dm/py/hls/publicsenat.m3u8 + +# ============================================================ +# BFM LOCAL +# ============================================================ + +#EXTINF:-1 tvg-id="BFMLyon.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/BFM_Lyon_logo_2020.svg/200px-BFM_Lyon_logo_2020.svg.png" group-title="BFM Local",BFM Lyon +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_LYON/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMMarseille.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/BFM_Marseille_Provence_logo_2022.svg/200px-BFM_Marseille_Provence_logo_2022.svg.png" group-title="BFM Local",BFM Marseille Provence +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_MARSEILLEPROV/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMGrandLille.fr@SD" group-title="BFM Local",BFM Grand Lille +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFMGRANDLILLE/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMCotedAzur.fr@SD" group-title="BFM Local",BFM Nice Côte d'Azur +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_NICECOTEDAZUR/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMNormandie.fr@SD" group-title="BFM Local",BFM Normandie +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_NORMANDIE/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMAlsace.fr@SD" group-title="BFM Local",BFM Alsace +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_ALSACE/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMGrandLittoral.fr@SD" group-title="BFM Local",BFM Grand Littoral +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFMGRANDLITTORAL/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="BFMVar.fr@SD" group-title="BFM Local",BFM Toulon Var +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_TOULONVAR/index.m3u8?end=END&start=LIVE + +# ============================================================ +# SONY ONE FRANCE (AWS MediaTailor - HD 1080p) +# ============================================================ + +#EXTINF:-1 tvg-id="SonyOneBlacklist.fr@HD" tvg-logo="https://i.imgur.com/uJC8rXr.png" group-title="Sony One France",Sony One Blacklist +https://06cb85ad6ccb4a6b97f561e62d16ad3f.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-822-FR-SONYONETHEBLACKLIST-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneFavoris.fr@HD" tvg-logo="https://i.imgur.com/RO4AM4b.png" group-title="Sony One France",Sony One Favoris +https://49d735318d6b4c30a24a7997ea594e1b.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-820-FR-SONYONEFAVORIS-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneHitsAction.fr@HD" tvg-logo="https://i.imgur.com/pXsZEsR.png" group-title="Sony One France",Sony One Hits Action +https://5098a8b860504a3690fd2e7c0a18d68f.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-817-FR-SONYONEHITSACTION-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneHitsComedie.fr@HD" tvg-logo="https://i.imgur.com/8sHuxxS.png" group-title="Sony One France",Sony One Hits Comédie +https://7aa9671895264ec9a384dfed1b992171.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-818-FR-SONYONEHITSCOMDIE-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneSeriesComedie.fr@HD" tvg-logo="https://i.ibb.co/ns4Md77B/FR-FAST-Plex-Comedy-TV-logo-dark.png" group-title="Sony One France",Sony One Séries Comédie +https://4f2a3e1ff5274297b115cf0f7da1c2cd.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-819-FR-SONYONESRIESCOMDIE-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneSeriesThriller.fr@HD" tvg-logo="https://i.imgur.com/RVS8LDj.png" group-title="Sony One France",Sony One Séries Thriller +https://483a1e90c18641c9a6d27becd41ad892.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-821-FR-SONYONESRIESTHRILLER-LG_FR/playlist.m3u8 + +# ============================================================ +# INTERNATIONAL FRANCOPHONE +# ============================================================ + +#EXTINF:-1 tvg-id="France24.fr@French" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/France_24_logo.svg/200px-France_24_logo.svg.png" group-title="International",France 24 +https://live.france24.com/hls/live/2037179-b/F24_FR_HI_HLS/master_5000.m3u8 + +#EXTINF:-1 tvg-id="EuronewsFrench.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Euronews_logo_2022.svg/200px-Euronews_logo_2022.svg.png" group-title="International",Euronews Français +https://2f6c5bf4.wurl.com/master/f36d25e7e52f1ba8d7e56eb859c636563214f541/UmxheHhUVi1ldV9FdXJvbmV3c0ZyYW5jYWlzX0hMUw/playlist.m3u8 + +#EXTINF:-1 tvg-id="TV5MondeEurope.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/TV5MONDE_logo_2018.svg/200px-TV5MONDE_logo_2018.svg.png" group-title="International",TV5Monde Europe +https://ott.tv5monde.com/Content/HLS/Live/channel(europe)/variant.m3u8 + +#EXTINF:-1 tvg-id="TV5MondeInfo.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/TV5MONDE_logo_2018.svg/200px-TV5MONDE_logo_2018.svg.png" group-title="International",TV5Monde Info +https://ott.tv5monde.com/Content/HLS/Live/channel(info)/variant.m3u8 + +#EXTINF:-1 tvg-id="CGTNFrench.cn" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/CGTN_%28new_logo%29.svg/200px-CGTN_%28new_logo%29.svg.png" group-title="International",CGTN Français +https://news.cgtn.com/resource/live/french/cgtn-f.m3u8 + +#EXTINF:-1 tvg-id="Africa24.fr@SD" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Africa24_Logo.svg/200px-Africa24_Logo.svg.png" group-title="International",Africa 24 +https://africa24.vedge.infomaniak.com/livecast/ik:africa24/manifest.m3u8 + +# ============================================================ +# DIVERS +# ============================================================ + +#EXTINF:-1 tvg-id="ADN.fr@SD" group-title="Divers",ADN TV+ +https://d3b73b34o7cvkq.cloudfront.net/v1/master/3722c60a815c199d9c0ef36c5b73da68a62b09d1/cc-gz2sgqzp076kf/adn.m3u8 + +#EXTINF:-1 tvg-id="20MinutesTV.fr@SD" group-title="Divers",20 Minutes TV +https://live-20minutestv.digiteka.com/1961167769/index.m3u8 + +#EXTINF:-1 tvg-id="RTL2.fr@SD" group-title="Divers",RTL2 +https://raw.githubusercontent.com/Sibprod/streams/main/ressources/dm/py/hls/rtl2.m3u8 + +# ============================================================ +# SOURCES COMPLETES (playlists iptv-org a jour) +# Utiliser directement dans VLC : Media > Ouvrir un flux reseau +# ============================================================ +# https://iptv-org.github.io/iptv/countries/fr.m3u (France uniquement) +# https://iptv-org.github.io/iptv/languages/fra.m3u (toutes chaines en francais) +# https://raw.githubusercontent.com/Free-TV/IPTV/master/playlists/playlist_france.m3u8 diff --git a/error.md b/error.md new file mode 100644 index 0000000..a71cf1d --- /dev/null +++ b/error.md @@ -0,0 +1,20 @@ +=KEY_HOMEPAGE found=true +2026-03-15T13:32:56.417224Z INFO pilot_v2::keycode: keycode: key pressed device=/dev/input/event16 key=KEY_HOMEPAGE code=172 +2026-03-15T13:32:56.417523Z INFO pilot_v2::runtime: keycode received key=KEY_HOMEPAGE +2026-03-15T13:32:56.417614Z INFO pilot_v2::runtime: keycode: binding lookup key=KEY_HOMEPAGE found=true +25120 +2026-03-15T13:32:56.549211Z INFO pilot_v2::runtime: key binding: stopping app app=livebox_tv +2026-03-15T13:32:56.662775Z INFO pilot_v2::runtime: key binding: double press key=KEY_HOMEPAGE action=livebox_tv +25120 +2026-03-15T13:32:59.848170Z INFO pilot_v2::keycode: keycode: key pressed device=/dev/input/event16 key=KEY_HOMEPAGE code=172 +2026-03-15T13:32:59.848290Z INFO pilot_v2::runtime: keycode received key=KEY_HOMEPAGE +2026-03-15T13:32:59.848372Z INFO pilot_v2::runtime: keycode: binding lookup key=KEY_HOMEPAGE found=true +25120 +2026-03-15T13:33:00.083099Z INFO pilot_v2::keycode: keycode: key pressed device=/dev/input/event16 key=KEY_HOMEPAGE code=172 +2026-03-15T13:33:00.083265Z INFO pilot_v2::runtime: keycode received key=KEY_HOMEPAGE +2026-03-15T13:33:00.083364Z INFO pilot_v2::runtime: keycode: binding lookup key=KEY_HOMEPAGE found=true +25120 +2026-03-15T13:33:00.117737Z INFO pilot_v2::runtime: key binding: stopping app app=livebox_tv +2026-03-15T13:33:00.147219Z INFO pilot_v2::runtime: key binding: double press key=KEY_HOMEPAGE action=livebox_tv +25120 + diff --git a/iptv/france_tv.m3u b/iptv/france_tv.m3u new file mode 100644 index 0000000..3bc3da2 --- /dev/null +++ b/iptv/france_tv.m3u @@ -0,0 +1,127 @@ +#EXTM3U x-tvg-url="https://www.epgshare01.online/epg01/epg01_ALL.xml.gz" +# Chaines francaises IPTV - Pilot yoga14 +# Mis a jour: 2026-03-15 +# Sources: iptv-org.github.io/iptv/countries/fr.m3u + Free-TV/IPTV +# +# Note: les chaines marquees [Geo] sont bloquees hors Suisse/Europe. +# Pour VLC: ouvrir ce fichier via Fichier > Ouvrir un fichier reseau ou en argument. + +# ============================================================ +# TNT FRANCE - Flux directs (fonctionnent depuis la France) +# ============================================================ + +#EXTINF:-1 tvg-id="arte.fr@SD" tvg-logo="https://i.imgur.com/6BTqX4j.png" group-title="TNT France",Arte +https://artesimulcast.akamaized.net/hls/live/2031003/artelive_fr/index.m3u8 + +#EXTINF:-1 tvg-id="20MinutesTV.fr" tvg-logo="" group-title="TNT France",20 Minutes TV +https://live-20minutestv.digiteka.com/1961167769/index.m3u8 + + +# ============================================================ +# INFO / ACTUALITES - Flux directs +# ============================================================ + +#EXTINF:-1 tvg-id="BFMTV.fr@SD" tvg-logo="https://i.imgur.com/4hDmSvz.png" group-title="Info",BFM TV +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_TV/index.m3u8?end=END&start=LIVE + +#EXTINF:-1 tvg-id="France24.fr@French" tvg-logo="https://i.imgur.com/BKZQM4C.png" group-title="Info",France 24 Francais +https://live.france24.com/hls/live/2037179-b/F24_FR_HI_HLS/master_5000.m3u8 + +#EXTINF:-1 tvg-id="EuronewsFrench.fr@SD" tvg-logo="https://i.imgur.com/CJpMVBH.png" group-title="Info",Euronews Francais +https://2f6c5bf4.wurl.com/master/f36d25e7e52f1ba8d7e56eb859c636563214f541/UmxheHhUVi1ldV9FdXJvbmV3c0ZyYW5jYWlzX0hMUw/playlist.m3u8 + +#EXTINF:-1 tvg-id="LCI.fr@SD" tvg-logo="https://i.imgur.com/hnbkBaT.png" group-title="Info",LCI [Geo] +https://viamotionhsi.netplus.ch/live/eds/lci/browser-HLS8/lci.m3u8 + + + +# ============================================================ +# BFM LOCAL - Flux directs +# ============================================================ + + +#EXTINF:-1 tvg-id="BFMTechCo.fr@SD" tvg-logo="" group-title="BFM Local",BFM Tech et Co +#EXTVLCOPT:http-user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 +https://ncdn-live-bfm.pfd.sfr.net/shls/LIVE$BFM_TECHANDCO/index.m3u8?end=END&start=LIVE + +# ============================================================ +# SONY CHANNELS FRANCE (FAST - sans abonnement, 1080p) +# ============================================================ + +#EXTINF:-1 tvg-id="SonyOneBlacklist.fr@HD" tvg-logo="https://i.imgur.com/uJC8rXr.png" group-title="Sony France",Sony One Blacklist +https://06cb85ad6ccb4a6b97f561e62d16ad3f.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-822-FR-SONYONETHEBLACKLIST-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneFavoris.fr@HD" tvg-logo="https://i.imgur.com/RO4AM4b.png" group-title="Sony France",Sony One Favoris +https://49d735318d6b4c30a24a7997ea594e1b.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-820-FR-SONYONEFAVORIS-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneHitsAction.fr@HD" tvg-logo="https://i.imgur.com/pXsZEsR.png" group-title="Sony France",Sony One Hits Action +https://5098a8b860504a3690fd2e7c0a18d68f.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-817-FR-SONYONEHITSACTION-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneHitsComedie.fr@HD" tvg-logo="https://i.imgur.com/8sHuxxS.png" group-title="Sony France",Sony One Hits Comedie +https://7aa9671895264ec9a384dfed1b992171.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-818-FR-SONYONEHITSCOMDIE-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneSeriesComedie.fr@HD" tvg-logo="https://i.ibb.co/ns4Md77B/FR-FAST-Plex-Comedy-TV-logo-dark.png" group-title="Sony France",Sony One Series Comedie +https://4f2a3e1ff5274297b115cf0f7da1c2cd.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-819-FR-SONYONESRIESCOMDIE-LG_FR/playlist.m3u8 + +#EXTINF:-1 tvg-id="SonyOneSeriesThriller.fr@HD" tvg-logo="https://i.imgur.com/RVS8LDj.png" group-title="Sony France",Sony One Series Thriller +https://483a1e90c18641c9a6d27becd41ad892.mediatailor.us-west-2.amazonaws.com/v1/master/ba62fe743df0fe93366eba3a257d792884136c7f/LINEAR-821-FR-SONYONESRIESTHRILLER-LG_FR/playlist.m3u8 + +# ============================================================ +# INTERNATIONAL FRANCOPHONE +# ============================================================ + + +#EXTINF:-1 tvg-id="TV5MondeFBS.fr@SD" tvg-logo="https://i.imgur.com/GEzM6kd.png" group-title="International",TV5Monde FBS +https://ott.tv5monde.com/Content/HLS/Live/channel(fbs)/variant.m3u8 + +#EXTINF:-1 tvg-id="TV5MondeInfo.fr@SD" tvg-logo="https://i.imgur.com/GEzM6kd.png" group-title="International",TV5Monde Info +https://ott.tv5monde.com/Content/HLS/Live/channel(info)/variant.m3u8 + + +# ============================================================ +# TNT FRANCE - Flux geo-bloques (Swiss CDN, testez depuis EU) +# ============================================================ + +#EXTINF:-1 tvg-id="TF1.fr@HD" tvg-logo="https://i.imgur.com/ATzKSGt.png" group-title="TNT [Geo]",TF1 HD [Geo] +https://viamotionhsi.netplus.ch/live/eds/tf1hd/browser-HLS8/tf1hd.m3u8 + +#EXTINF:-1 tvg-id="France2.fr@HD" tvg-logo="https://i.imgur.com/lqfnPbQ.png" group-title="TNT [Geo]",France 2 HD [Geo] +https://viamotionhsi.netplus.ch/live/eds/france2hd/browser-HLS8/france2hd.m3u8 + + +#EXTINF:-1 tvg-id="France5.fr@HD" tvg-logo="" group-title="TNT [Geo]",France 5 HD [Geo] +https://viamotionhsi.netplus.ch/live/eds/france5hd/browser-HLS8/france5hd.m3u8 + +#EXTINF:-1 tvg-id="M6.fr@SD" tvg-logo="https://i.imgur.com/vFSBdA8.png" group-title="TNT [Geo]",M6 [Geo] +https://viamotionhsi.netplus.ch/live/eds/m6hd/browser-HLS8/m6hd.m3u8 + + +#EXTINF:-1 tvg-id="TFX.fr@SD" tvg-logo="" group-title="TNT [Geo]",TFX [Geo] +https://viamotionhsi.netplus.ch/live/eds/nt1/browser-HLS8/nt1.m3u8 + + + +#EXTINF:-1 tvg-id="CStar.fr@SD" tvg-logo="" group-title="TNT [Geo]",C Star [Geo] +https://viamotionhsi.netplus.ch/live/eds/d17/browser-HLS8/d17.m3u8 + +#EXTINF:-1 tvg-id="Gulli.fr@SD" tvg-logo="" group-title="TNT [Geo]",Gulli [Geo] +https://viamotionhsi.netplus.ch/live/eds/gulli/browser-HLS8/gulli.m3u8 + + +#EXTINF:-1 tvg-id="RMCStory.fr@SD" tvg-logo="" group-title="TNT [Geo]",RMC Story [Geo] +https://viamotionhsi.netplus.ch/live/eds/numero23/browser-HLS8/numero23.m3u8 + +#EXTINF:-1 tvg-id="RMCDecouverte.fr@SD" tvg-logo="" group-title="TNT [Geo]",RMC Decouverte [Geo] +https://viamotionhsi.netplus.ch/live/eds/rmcdecouverte/browser-HLS8/rmcdecouverte.m3u8 + +#EXTINF:-1 tvg-id="CNews.fr@SD" tvg-logo="" group-title="TNT [Geo]",CNews [Geo] +https://viamotionhsi.netplus.ch/live/eds/itele/browser-HLS8/itele.m3u8 + +#EXTINF:-1 tvg-id="RTL9.lu@SD" tvg-logo="" group-title="TNT [Geo]",RTL9 [Geo] +https://viamotionhsi.netplus.ch/live/eds/rtl9/browser-HLS8/rtl9.m3u8 + +#EXTINF:-1 tvg-id="PlanetePlus.fr@SD" tvg-logo="" group-title="Cable [Geo]",Planete+ [Geo] +https://viamotionhsi.netplus.ch/live/eds/planeteplus/browser-HLS8/planeteplus.m3u8 + + diff --git a/packaging/pilot.service b/packaging/pilot.service index ade85d6..fc0ad41 100644 --- a/packaging/pilot.service +++ b/packaging/pilot.service @@ -1,16 +1,23 @@ [Unit] Description=Pilot v2 MQTT Agent -After=network-online.target +Documentation=https://gitea.maison43.duckdns.org/gilles/pilot +After=network-online.target graphical-session.target Wants=network-online.target [Service] Type=simple -User=pilot -WorkingDirectory=/opt/pilot -ExecStart=/opt/pilot/pilot +WorkingDirectory=%h/pilot/pilot-v2 +ExecStart=%h/pilot/pilot-v2/target/release/pilot-v2 Restart=on-failure RestartSec=5 + Environment=RUST_LOG=info +Environment=XDG_RUNTIME_DIR=/run/user/%U +Environment=WAYLAND_DISPLAY=wayland-0 +Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus + +KillSignal=SIGTERM +TimeoutStopSec=10 [Install] -WantedBy=multi-user.target +WantedBy=default.target diff --git a/pilot-v2/config.yaml b/pilot-v2/config.yaml index 64b39cd..18ee0d2 100644 --- a/pilot-v2/config.yaml +++ b/pilot-v2/config.yaml @@ -1,28 +1,25 @@ -# Codex created 2025-12-29_0224 +# Configuration Pilot v2 - yoga14 (Lenovo Yoga) +# Hostname auto-detecte: yoga14 + device: name: $hostname identifiers: ["$hostname"] - manufacturer: "Asus" - model: "Laptop" + manufacturer: "Lenovo" + model: "Yoga 14" sw_version: "2.0.0" suggested_area: "Bureau" mqtt: - host: "10.0.0.3" + host: "10.0.0.3" # <- adresse de ton serveur Home Assistant / broker Mosquitto port: 1883 - username: "" + username: "" # <- si authentification activee sur Mosquitto password: "" base_topic: "pilot" discovery_prefix: "homeassistant" client_id: "$hostname" keepalive_s: 60 - qos: 0 + qos: 1 retain_states: true - reconnect: - attempts: 3 - retry_delay_s: 1 - short_wait_s: 60 - long_wait_s: 3600 features: telemetry: @@ -38,33 +35,13 @@ features: device_class: "" icon: "mdi:chip" state_class: "measurement" - pilot_v2_cpu_usage: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "Pilot V2 CPU Usage" - unique_id: "$hostname_pilot_v2_cpu_usage" - unit: "%" - device_class: "" - icon: "mdi:apps" - state_class: "measurement" - pilot_v2_mem_used_mb: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "Pilot V2 Memory Used" - unique_id: "$hostname_pilot_v2_mem_used_mb" - unit: "MB" - device_class: "" - icon: "mdi:memory" - state_class: "measurement" cpu_temp_c: enabled: true discovery_enabled: true - interval_s: 30 + interval_s: 10 name: "CPU Temp" unique_id: "$hostname_cpu_temp" - unit: "°C" + unit: "C" device_class: "temperature" icon: "mdi:thermometer" state_class: "measurement" @@ -74,114 +51,35 @@ features: interval_s: 60 name: "SSD Temp" unique_id: "$hostname_ssd_temp" - unit: "°C" + unit: "C" device_class: "temperature" icon: "mdi:thermometer" state_class: "measurement" - gpu_usage: - enabled: true - discovery_enabled: true + # GPU integre AMD (desactive - donnees non fiables sur ce modele) + amd_gpu_usage: + enabled: false + discovery_enabled: false interval_s: 10 name: "GPU Usage" - unique_id: "$hostname_gpu_usage" - unit: "%" - device_class: "" - icon: "mdi:expansion-card" - state_class: "measurement" - gpu0_usage: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "GPU0 Usage" - unique_id: "$hostname_gpu0_usage" - unit: "%" - device_class: "" - icon: "mdi:expansion-card" - state_class: "measurement" - gpu1_usage: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "GPU1 Usage" - unique_id: "$hostname_gpu1_usage" - unit: "%" - device_class: "" - icon: "mdi:expansion-card" - state_class: "measurement" - gpu0_temp_c: - enabled: true - discovery_enabled: true - interval_s: 30 - name: "GPU0 Temp" - unique_id: "$hostname_gpu0_temp" - unit: "°C" - device_class: "temperature" - icon: "mdi:thermometer" - state_class: "measurement" - gpu1_temp_c: - enabled: true - discovery_enabled: true - interval_s: 30 - name: "GPU1 Temp" - unique_id: "$hostname_gpu1_temp" - unit: "°C" - device_class: "temperature" - icon: "mdi:thermometer" - state_class: "measurement" - gpu0_mem_used_gb: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "GPU0 Memory Used" - unique_id: "$hostname_gpu0_mem_used" - unit: "GB" - device_class: "" - icon: "mdi:memory" - state_class: "measurement" - gpu1_mem_used_gb: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "GPU1 Memory Used" - unique_id: "$hostname_gpu1_mem_used" - unit: "GB" - device_class: "" - icon: "mdi:memory" - state_class: "measurement" - amd_gpu_usage: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "AMD GPU Usage" unique_id: "$hostname_amd_gpu_usage" unit: "%" device_class: "" - icon: "mdi:expansion-card" + icon: "mdi:gpu" state_class: "measurement" amd_gpu_temp_c: - enabled: true - discovery_enabled: true - interval_s: 30 - name: "AMD GPU Temp" + enabled: false + discovery_enabled: false + interval_s: 10 + name: "GPU Temp" unique_id: "$hostname_amd_gpu_temp" - unit: "°C" + unit: "C" device_class: "temperature" icon: "mdi:thermometer" state_class: "measurement" - amd_gpu_mem_used_gb: - enabled: true - discovery_enabled: true - interval_s: 10 - name: "AMD GPU Memory Used" - unique_id: "$hostname_amd_gpu_mem_used" - unit: "GB" - device_class: "" - icon: "mdi:memory" - state_class: "measurement" memory_used_gb: enabled: true discovery_enabled: true - interval_s: 20 + interval_s: 10 name: "Memory Used" unique_id: "$hostname_memory_used" unit: "GB" @@ -191,7 +89,7 @@ features: memory_total_gb: enabled: true discovery_enabled: true - interval_s: 240 + interval_s: 3600 name: "Memory Total" unique_id: "$hostname_memory_total" unit: "GB" @@ -201,47 +99,27 @@ features: disk_free_gb: enabled: true discovery_enabled: true - interval_s: 240 + interval_s: 120 name: "Disk Free" unique_id: "$hostname_disk_free" unit: "GB" device_class: "" icon: "mdi:harddisk" state_class: "measurement" - fan_cpu_rpm: - enabled: true - discovery_enabled: true - interval_s: 23 - name: "CPU Fan" - unique_id: "$hostname_fan_cpu" - unit: "RPM" - device_class: "" - icon: "mdi:fan" - state_class: "measurement" - fan_gpu_rpm: - enabled: true - discovery_enabled: true - interval_s: 23 - name: "GPU Fan" - unique_id: "$hostname_fan_gpu" - unit: "RPM" - device_class: "" - icon: "mdi:fan" - state_class: "measurement" ip_address: enabled: true discovery_enabled: true - interval_s: 1200 + interval_s: 120 name: "IP Address" unique_id: "$hostname_ip" unit: "" device_class: "" - icon: "mdi:ip-network" + icon: "mdi:ip" state_class: "" battery_level: enabled: true discovery_enabled: true - interval_s: 240 + interval_s: 60 name: "Battery Level" unique_id: "$hostname_battery_level" unit: "%" @@ -251,7 +129,7 @@ features: battery_state: enabled: true discovery_enabled: true - interval_s: 240 + interval_s: 60 name: "Battery State" unique_id: "$hostname_battery_state" unit: "" @@ -261,7 +139,7 @@ features: power_state: enabled: true discovery_enabled: true - interval_s: 240 + interval_s: 60 name: "Power State" unique_id: "$hostname_power_state" unit: "" @@ -271,7 +149,7 @@ features: kernel: enabled: true discovery_enabled: true - interval_s: 14400 + interval_s: 7200 name: "Kernel" unique_id: "$hostname_kernel" unit: "" @@ -281,32 +159,103 @@ features: os_version: enabled: true discovery_enabled: true - interval_s: 14400 + interval_s: 7200 name: "OS Version" unique_id: "$hostname_os_version" unit: "" device_class: "" - icon: "mdi:monitor" + icon: "mdi:desktop-classic" state_class: "" + volume_level: + enabled: true + discovery_enabled: true + interval_s: 30 + name: "Volume Level" + unique_id: "$hostname_volume_level" + unit: "%" + device_class: "" + icon: "mdi:volume-high" + state_class: "measurement" commands: enabled: true cooldown_s: 5 - dry_run: false # true = simule les commandes sans les executer - allowlist: ["shutdown", "reboot", "sleep", "screen"] + dry_run: false + allowlist: + - "shutdown" + - "reboot" + - "sleep" + - "hibernate" + - "screen" + - "volume" + - "system_update" + - "inhibit_sleep" + - "app_vacuum_tube" + - "app_livebox_tv" + - "bluetooth_k3pro" + - "bluetooth_g7bts" + - "livebox_tv_channel" power_backend: - linux: "linux_logind_polkit" # or linux_sudoers + linux: "linux_logind_polkit" windows: "windows_service" screen_backend: - linux: "x11_xset" #"gnome_busctl" # or "x11_xset" - windows: "winapi_session" # or external_tool + linux: "gnome_busctl" # si pas GNOME: x11_xset + windows: "winapi_session" publish: heartbeat_s: 30 availability: true +apps: + - name: "vacuum_tube" + display_name: "VacuumTube" + enabled: true + start_cmd: "flatpak" + start_args: ["run", "--device=dri", "rocks.shy.VacuumTube"] + process_check: "vacuumtube" + - name: "livebox_tv" + display_name: "Livebox TV" + enabled: true + start_cmd: "vlc" + start_args: + - "--fullscreen" + - "--network-caching=1000" + - "../iptv/france_tv.m3u" + process_check: "vlc" + channels_m3u: "../iptv/france_tv.m3u" + channel_next_key: "KEY_PAGEUP" + channel_prev_key: "KEY_PAGEDOWN" + +bluetooth: + enabled: true + devices: + - name: "k3pro" + mac: "F1:B7:7F:BC:7B:00" + display_name: "ThinkPlus K3 Pro" + - name: "g7bts" + mac: "AA:23:02:16:32:6F" + display_name: "Rii G7BTS" + paths: linux_config: "/etc/pilot/config.yaml" windows_config: "C:\\ProgramData\\Pilot\\config.yaml" -# Codex modified 2025-12-29_0224 + +# Lecture des touches clavier/telecommande via evdev +# Necessite: utilisateur dans le groupe 'input' (sudo usermod -aG input $USER) +# Pour trouver le device: ls -la /dev/input/by-id/ apres connexion de la telecommande +keycodes: + enabled: true + devices: + - "G7BTS Keyboard" # nom tel qu'il apparait dans /sys/class/input/*/device/name + +# Liaisons touches → actions (simple / double appui) +key_bindings: + enabled: true + bindings: + - key: "KEY_HOMEPAGE" + single_press: "vacuum_tube" # appui simple → toggle VacuumTube + double_press: "livebox_tv" # double appui → toggle LiveboxTV + double_press_ms: 400 # fenetre de detection en ms + - key: "KEY_OK" + single_press: "key:28" # touche OK → injecte Enter (code 28) via ydotool diff --git a/pilot-v2/src/commands/mod.rs b/pilot-v2/src/commands/mod.rs index e2162db..8e8973d 100644 --- a/pilot-v2/src/commands/mod.rs +++ b/pilot-v2/src/commands/mod.rs @@ -3,11 +3,12 @@ use anyhow::{bail, Result}; use std::time::{Duration, Instant}; use tracing::info; -// Actions d'alimentation supportees (shutdown/reboot/sleep). +// Actions d'alimentation supportees (shutdown/reboot/sleep/hibernate). pub trait PowerControl { fn shutdown(&self) -> Result<()>; fn reboot(&self) -> Result<()>; fn sleep(&self) -> Result<()>; + fn hibernate(&self) -> Result<()>; } // Actions d'ecran supportees (on/off). @@ -16,18 +17,29 @@ pub trait ScreenControl { fn screen_off(&self) -> Result<()>; } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum CommandAction { Shutdown, Reboot, Sleep, + Hibernate, Screen, + Volume, + App(String), + Bluetooth(String), + SystemUpdate, + InhibitSleep, + /// Selecteur de chaine TV pour une app (ex: TvChannel("livebox_tv")). + TvChannel(String), } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum CommandValue { On, Off, + Number(u8), + /// Valeur texte libre (ex: nom de chaine pour select HA). + Text(String), } // Decode une action depuis le topic cmd//set. @@ -41,28 +53,65 @@ pub fn parse_action(topic: &str) -> Result { "shutdown" => Ok(CommandAction::Shutdown), "reboot" => Ok(CommandAction::Reboot), "sleep" => Ok(CommandAction::Sleep), + "hibernate" => Ok(CommandAction::Hibernate), "screen" => Ok(CommandAction::Screen), + "volume" => Ok(CommandAction::Volume), + "system_update" => Ok(CommandAction::SystemUpdate), + "inhibit_sleep" => Ok(CommandAction::InhibitSleep), + other if other.ends_with("_channel") => { + let app = other.trim_end_matches("_channel"); + Ok(CommandAction::TvChannel(app.to_string())) + } + other if other.starts_with("app_") => { + Ok(CommandAction::App(other.trim_start_matches("app_").to_string())) + } + other if other.starts_with("bluetooth_") => { + Ok(CommandAction::Bluetooth(other.trim_start_matches("bluetooth_").to_string())) + } _ => bail!("unknown action"), } } -// Decode une valeur ON/OFF (insensible a la casse). +// Decode une valeur ON/OFF, numerique (0-100) ou texte libre (ex: nom de chaine). pub fn parse_value(payload: &[u8]) -> Result { - let raw = String::from_utf8_lossy(payload).trim().to_uppercase(); - match raw.as_str() { + let raw = String::from_utf8_lossy(payload).trim().to_string(); + match raw.to_uppercase().as_str() { "ON" => Ok(CommandValue::On), "OFF" => Ok(CommandValue::Off), - _ => bail!("invalid payload"), + _ => { + if let Ok(n) = raw.parse::() { + Ok(CommandValue::Number(n)) + } else { + Ok(CommandValue::Text(raw)) + } + } + } +} + +// Convertit une action en nom utilise par la config et les topics MQTT. +pub fn action_name(action: &CommandAction) -> String { + match action { + CommandAction::Shutdown => "shutdown".to_string(), + CommandAction::Reboot => "reboot".to_string(), + CommandAction::Sleep => "sleep".to_string(), + CommandAction::Hibernate => "hibernate".to_string(), + CommandAction::Screen => "screen".to_string(), + CommandAction::Volume => "volume".to_string(), + CommandAction::SystemUpdate => "system_update".to_string(), + CommandAction::InhibitSleep => "inhibit_sleep".to_string(), + CommandAction::App(name) => format!("app_{}", name), + CommandAction::Bluetooth(name) => format!("bluetooth_{}", name), + CommandAction::TvChannel(name) => format!("{}_channel", name), } } // Verifie si l'action est autorisee par l'allowlist (vide = tout autoriser). -pub fn allowlist_allows(allowlist: &[String], action: CommandAction) -> bool { +pub fn allowlist_allows(allowlist: &[String], action: &CommandAction) -> bool { if allowlist.is_empty() { return true; } let name = action_name(action); - allowlist.iter().any(|item| item == name) + allowlist.iter().any(|item| item == &name) } // Verifie le cooldown et renvoie true si l'action est autorisee. @@ -82,21 +131,11 @@ pub fn allow_command( } // Execute une commande en mode dry-run (journalise seulement). -pub fn execute_dry_run(action: CommandAction, value: CommandValue) -> Result<()> { +pub fn execute_dry_run(action: &CommandAction, value: &CommandValue) -> Result<()> { info!(?action, ?value, "dry-run command"); Ok(()) } -// Convertit une action en nom utilise par la config. -pub fn action_name(action: CommandAction) -> &'static str { - match action { - CommandAction::Shutdown => "shutdown", - CommandAction::Reboot => "reboot", - CommandAction::Sleep => "sleep", - CommandAction::Screen => "screen", - } -} - #[cfg(test)] mod tests { use super::*; @@ -107,17 +146,36 @@ mod tests { assert_eq!(parse_action(topic).unwrap(), CommandAction::Shutdown); } + #[test] + fn parse_action_hibernate() { + let topic = "pilot/device/cmd/hibernate/set"; + assert_eq!(parse_action(topic).unwrap(), CommandAction::Hibernate); + } + + #[test] + fn parse_action_app() { + let topic = "pilot/device/cmd/app_vacuum_tube/set"; + assert_eq!(parse_action(topic).unwrap(), CommandAction::App("vacuum_tube".to_string())); + } + + #[test] + fn parse_action_bluetooth() { + let topic = "pilot/device/cmd/bluetooth_k3pro/set"; + assert_eq!(parse_action(topic).unwrap(), CommandAction::Bluetooth("k3pro".to_string())); + } + #[test] fn parse_value_ok() { assert!(matches!(parse_value(b"ON").unwrap(), CommandValue::On)); assert!(matches!(parse_value(b"off").unwrap(), CommandValue::Off)); + assert!(matches!(parse_value(b"75").unwrap(), CommandValue::Number(75))); } #[test] fn allowlist_checks() { let list = vec!["shutdown".to_string(), "screen".to_string()]; - assert!(allowlist_allows(&list, CommandAction::Shutdown)); - assert!(!allowlist_allows(&list, CommandAction::Reboot)); + assert!(allowlist_allows(&list, &CommandAction::Shutdown)); + assert!(!allowlist_allows(&list, &CommandAction::Reboot)); } #[test] diff --git a/pilot-v2/src/config/mod.rs b/pilot-v2/src/config/mod.rs index 3897481..534ad5f 100644 --- a/pilot-v2/src/config/mod.rs +++ b/pilot-v2/src/config/mod.rs @@ -15,6 +15,14 @@ pub struct Config { pub screen_backend: ScreenBackend, pub publish: Publish, pub paths: Option, + #[serde(default)] + pub apps: Vec, + #[serde(default)] + pub bluetooth: BluetoothSettings, + #[serde(default)] + pub keycodes: KeycodeConfig, + #[serde(default)] + pub key_bindings: KeyBindingsConfig, } #[derive(Debug, Clone, Deserialize)] @@ -547,6 +555,87 @@ pub struct Paths { pub windows_config: String, } +// Configuration d'une application pilotable (start/stop depuis HA). +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub name: String, + pub display_name: String, + #[serde(default = "default_true")] + pub enabled: bool, + pub start_cmd: String, + #[serde(default)] + pub start_args: Vec, + pub process_check: String, + /// Chemin vers un fichier M3U pour activer le selecteur de chaine (optionnel). + #[serde(default)] + pub channels_m3u: Option, + /// Touche (nom keycode) pour passer a la chaine suivante. + #[serde(default)] + pub channel_next_key: Option, + /// Touche (nom keycode) pour passer a la chaine precedente. + #[serde(default)] + pub channel_prev_key: Option, +} + +fn default_true() -> bool { + true +} + +fn default_double_press_ms() -> u64 { + 400 +} + +// Liaison touche → action (simple / double appui). +#[derive(Debug, Clone, Deserialize)] +pub struct KeyBinding { + /// Nom du keycode tel que publie par le module keycode (ex: "KEY_HOME"). + pub key: String, + /// Action sur appui simple : nom d'une app (ex: "vacuum_tube") ou commande. + #[serde(default)] + pub single_press: Option, + /// Action sur double appui. + #[serde(default)] + pub double_press: Option, + /// Fenetre de detection du double appui en millisecondes. + #[serde(default = "default_double_press_ms")] + pub double_press_ms: u64, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct KeyBindingsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub bindings: Vec, +} + +// Configuration du lecteur de touches (telecommande/clavier evdev). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct KeycodeConfig { + /// Active l'ecoute des evenements clavier. + #[serde(default)] + pub enabled: bool, + /// Chemins des devices a surveiller (ex: /dev/input/by-id/...). + #[serde(default)] + pub devices: Vec, +} + +// Configuration Bluetooth (appareils a surveiller/controler). +#[derive(Debug, Clone, Deserialize)] +pub struct BluetoothDevice { + pub name: String, + pub mac: String, + pub display_name: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct BluetoothSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub devices: Vec, +} + // Charge la config depuis les chemins par defaut (OS + fallback). pub fn load() -> Result { let candidates = candidate_paths(); @@ -767,8 +856,8 @@ publish: expand_variables(&mut cfg).unwrap(); let hostname = get_hostname().unwrap(); let metric = cfg.features.telemetry.metrics.get("cpu_usage").unwrap(); - assert_eq!(metric.unique_id.as_deref(), Some(&format!("{}_cpu_usage", hostname))); - assert_eq!(metric.name.as_deref(), Some(&format!("{} CPU Usage", hostname))); + assert_eq!(metric.unique_id.as_deref(), Some(format!("{}_cpu_usage", hostname).as_str())); + assert_eq!(metric.name.as_deref(), Some(format!("{} CPU Usage", hostname).as_str())); let hostname = get_hostname().unwrap(); assert_eq!(cfg.device.name, hostname); assert_eq!(cfg.device.identifiers[0], hostname); diff --git a/pilot-v2/src/ha/mod.rs b/pilot-v2/src/ha/mod.rs index 73cb3db..b4b9379 100644 --- a/pilot-v2/src/ha/mod.rs +++ b/pilot-v2/src/ha/mod.rs @@ -18,6 +18,7 @@ struct DeviceInfo { suggested_area: Option, } +// Entite generique (sensor, switch). #[derive(Serialize)] struct EntityConfig { name: String, @@ -43,6 +44,42 @@ struct EntityConfig { icon: Option, } +// Entite select HA (selecteur de chaine TV). +#[derive(Serialize)] +struct SelectEntityConfig { + name: String, + unique_id: String, + state_topic: String, + command_topic: String, + availability_topic: String, + payload_available: String, + payload_not_available: String, + options: Vec, + device: DeviceInfo, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, +} + +// Entite number HA (slider volume 0-100). +#[derive(Serialize)] +struct NumberEntityConfig { + name: String, + unique_id: String, + state_topic: String, + command_topic: String, + availability_topic: String, + payload_available: String, + payload_not_available: String, + device: DeviceInfo, + min: f32, + max: f32, + step: f32, + #[serde(skip_serializing_if = "Option::is_none")] + unit_of_measurement: Option, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, +} + // Publie les entites HA discovery pour les capteurs et commandes standard. pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> { let base = base_device_topic(cfg); @@ -91,32 +128,181 @@ pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> { } } - let switches = vec![ - ("shutdown", "Shutdown", "cmd/shutdown/set"), - ("reboot", "Reboot", "cmd/reboot/set"), - ("sleep", "Sleep", "cmd/sleep/set"), - ("screen", "Screen", "cmd/screen/set"), - ]; + if cfg.features.commands.enabled { + // Switches power/screen standard + let switches = vec![ + ("shutdown", "Shutdown", "cmd/shutdown/set", "mdi:power"), + ("reboot", "Reboot", "cmd/reboot/set", "mdi:restart"), + ("sleep", "Sleep", "cmd/sleep/set", "mdi:sleep"), + ("hibernate", "Hibernate", "cmd/hibernate/set", "mdi:snowflake"), + ("screen", "Screen", "cmd/screen/set", "mdi:monitor"), + ("system_update", "System Update", "cmd/system_update/set", "mdi:update"), + ("inhibit_sleep", "Inhibit Sleep", "cmd/inhibit_sleep/set", "mdi:sleep-off"), + ]; - for (key, _name, cmd) in switches { - let entity_name = format!("{}_{}", key, cfg.device.name); + for (key, label, cmd, icon) in switches { + // Ignore hibernate/system_update si pas dans l'allowlist + if !cfg.features.commands.allowlist.is_empty() + && !cfg.features.commands.allowlist.iter().any(|a| a == key) + { + continue; + } + let entity_name = format!("{}_{}", key, cfg.device.name); + let entity = EntityConfig { + name: format!("{} {}", label, cfg.device.name), + unique_id: format!("{}_{}", cfg.device.name, key), + state_topic: format!("{}/{}/state", base, key), + availability_topic: format!("{}/availability", base), + payload_available: "online".to_string(), + payload_not_available: "offline".to_string(), + device: DeviceInfo { ..device.clone() }, + command_topic: Some(format!("{}/{}", base, cmd)), + payload_on: Some("ON".to_string()), + payload_off: Some("OFF".to_string()), + unit_of_measurement: None, + device_class: Some("switch".to_string()), + state_class: None, + icon: Some(icon.to_string()), + }; + let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name); + publish_discovery(client, &topic, &entity).await?; + } + + // Entite number pour le volume + if cfg.features.commands.allowlist.is_empty() + || cfg.features.commands.allowlist.iter().any(|a| a == "volume") + { + let volume_entity = NumberEntityConfig { + name: format!("Volume {}", cfg.device.name), + unique_id: format!("{}_volume", cfg.device.name), + state_topic: format!("{}/volume/state", base), + command_topic: format!("{}/cmd/volume/set", base), + availability_topic: format!("{}/availability", base), + payload_available: "online".to_string(), + payload_not_available: "offline".to_string(), + device: DeviceInfo { ..device.clone() }, + min: 0.0, + max: 100.0, + step: 1.0, + unit_of_measurement: Some("%".to_string()), + icon: Some("mdi:volume-high".to_string()), + }; + let topic = format!( + "{}/number/{}/volume_{}/config", + prefix, cfg.device.name, cfg.device.name + ); + publish_discovery(client, &topic, &volume_entity).await?; + } + + // Switches pour les apps configurees + for app in &cfg.apps { + if !app.enabled { + continue; + } + let key = format!("app_{}", app.name); + if !cfg.features.commands.allowlist.is_empty() + && !cfg.features.commands.allowlist.iter().any(|a| a == &key) + { + continue; + } + let entity_name = format!("{}_{}", key, cfg.device.name); + let entity = EntityConfig { + name: format!("{} {}", app.display_name, cfg.device.name), + unique_id: format!("{}_{}", cfg.device.name, key), + state_topic: format!("{}/{}/state", base, key), + availability_topic: format!("{}/availability", base), + payload_available: "online".to_string(), + payload_not_available: "offline".to_string(), + device: DeviceInfo { ..device.clone() }, + command_topic: Some(format!("{}/cmd/{}/set", base, key)), + payload_on: Some("ON".to_string()), + payload_off: Some("OFF".to_string()), + unit_of_measurement: None, + device_class: Some("switch".to_string()), + state_class: None, + icon: Some("mdi:application".to_string()), + }; + let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name); + publish_discovery(client, &topic, &entity).await?; + + // Selecteur de chaine si channels_m3u est configure + if let Some(m3u_path) = &app.channels_m3u { + let channels = crate::m3u::parse_file(m3u_path); + if !channels.is_empty() { + let options: Vec = channels.into_iter().map(|(name, _)| name).collect(); + let channel_key = format!("{}_channel", app.name); + let channel_entity_name = format!("{}_{}", channel_key, cfg.device.name); + let select = SelectEntityConfig { + name: format!("{} Channel {}", app.display_name, cfg.device.name), + unique_id: format!("{}_{}", cfg.device.name, channel_key), + state_topic: format!("{}/{}/state", base, channel_key), + command_topic: format!("{}/cmd/{}/set", base, channel_key), + availability_topic: format!("{}/availability", base), + payload_available: "online".to_string(), + payload_not_available: "offline".to_string(), + options, + device: DeviceInfo { ..device.clone() }, + icon: Some("mdi:television-play".to_string()), + }; + let topic = format!("{}/select/{}/{}/config", prefix, cfg.device.name, channel_entity_name); + publish_discovery(client, &topic, &select).await?; + } + } + } + + // Switches pour les appareils Bluetooth + if cfg.bluetooth.enabled { + for dev in &cfg.bluetooth.devices { + let key = format!("bluetooth_{}", dev.name); + if !cfg.features.commands.allowlist.is_empty() + && !cfg.features.commands.allowlist.iter().any(|a| a == &key) + { + continue; + } + let entity_name = format!("{}_{}", key, cfg.device.name); + let entity = EntityConfig { + name: format!("{} {}", dev.display_name, cfg.device.name), + unique_id: format!("{}_{}", cfg.device.name, key), + state_topic: format!("{}/{}/state", base, key), + availability_topic: format!("{}/availability", base), + payload_available: "online".to_string(), + payload_not_available: "offline".to_string(), + device: DeviceInfo { ..device.clone() }, + command_topic: Some(format!("{}/cmd/{}/set", base, key)), + payload_on: Some("ON".to_string()), + payload_off: Some("OFF".to_string()), + unit_of_measurement: None, + device_class: Some("switch".to_string()), + state_class: None, + icon: Some("mdi:bluetooth".to_string()), + }; + let topic = + format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name); + publish_discovery(client, &topic, &entity).await?; + } + } + } + + // Capteur keycode (derniere touche pressee sur la telecommande) + if cfg.keycodes.enabled { let entity = EntityConfig { - name: entity_name.clone(), - unique_id: format!("{}_{}", cfg.device.name, key), - state_topic: format!("{}/{}/state", base, key), + name: format!("Keycode {}", cfg.device.name), + unique_id: format!("{}_keycode", cfg.device.name), + state_topic: format!("{}/keycode", base), availability_topic: format!("{}/availability", base), payload_available: "online".to_string(), payload_not_available: "offline".to_string(), device: DeviceInfo { ..device.clone() }, - command_topic: Some(format!("{}/{}", base, cmd)), - payload_on: Some("ON".to_string()), - payload_off: Some("OFF".to_string()), + command_topic: None, + payload_on: None, + payload_off: None, unit_of_measurement: None, - device_class: Some("switch".to_string()), + device_class: None, state_class: None, - icon: Some("mdi:power".to_string()), + icon: Some("mdi:remote".to_string()), }; - let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name); + let entity_name = format!("keycode_{}", cfg.device.name); + let topic = format!("{}/sensor/{}/{}/config", prefix, cfg.device.name, entity_name); publish_discovery(client, &topic, &entity).await?; } diff --git a/pilot-v2/src/keycode.rs b/pilot-v2/src/keycode.rs new file mode 100644 index 0000000..313c718 --- /dev/null +++ b/pilot-v2/src/keycode.rs @@ -0,0 +1,197 @@ +// Lecture des evenements clavier via evdev (lecture brute des fichiers /dev/input/event*). +// Necessite que l'utilisateur soit dans le groupe 'input'. +// +// Format input_event sur Linux 64-bit: +// [0..8] tv_sec (i64) +// [8..16] tv_usec (i64) +// [16..18] type (u16) +// [18..20] code (u16) +// [20..24] value (i32) +// Total: 24 bytes + +use std::fs::File; +use std::io::Read; +use tokio::sync::mpsc::UnboundedSender; +use tracing::{debug, info, warn}; + +const EV_KEY: u16 = 1; +const KEY_DOWN: i32 = 1; +const INPUT_EVENT_SIZE: usize = 24; + +/// Demarre les threads de lecture. +/// Supporte les chemins directs (/dev/input/eventX) ET les noms de device ("G7BTS Keyboard"). +/// Les noms sont resolus dynamiquement — gere la reconnexion Bluetooth automatiquement. +pub fn start_listener(devices: Vec, tx: UnboundedSender) { + for entry in devices { + let tx = tx.clone(); + std::thread::spawn(move || { + if entry.starts_with('/') { + // Chemin direct + device_loop(&entry, tx); + } else { + // Nom de device: resolution dynamique + device_loop_by_name(&entry, tx); + } + }); + } +} + +/// Trouve le chemin /dev/input/eventX pour un device par son nom. +pub fn find_device_by_name(name: &str) -> Option { + let entries = std::fs::read_dir("/sys/class/input").ok()?; + for entry in entries.flatten() { + let input_name = entry.file_name(); + let input_name_str = input_name.to_string_lossy(); + if !input_name_str.starts_with("event") { + continue; + } + let name_file = entry.path().join("device/name"); + if let Ok(dev_name) = std::fs::read_to_string(&name_file) { + if dev_name.trim().to_lowercase().contains(&name.to_lowercase()) { + return Some(format!("/dev/input/{}", input_name_str)); + } + } + } + None +} + +/// Boucle par nom: resout le chemin periodiquement pour gerer les reconnexions. +fn device_loop_by_name(name: &str, tx: UnboundedSender) { + loop { + match find_device_by_name(name) { + Some(path) => { + info!(device_name = %name, path = %path, "input device found"); + device_loop_once(&path, &tx); + // La boucle interne est sortie — device deconnecte ou erreur + warn!(device_name = %name, "input device disconnected, waiting for reconnect..."); + } + None => { + info!(device_name = %name, "keycode: input device not found, retrying in 5s"); + } + } + std::thread::sleep(std::time::Duration::from_secs(5)); + } +} + +/// Boucle principale pour un device par chemin fixe. +fn device_loop(path: &str, tx: UnboundedSender) { + let mut backoff_s = 2u64; + loop { + match File::open(path) { + Ok(mut file) => { + debug!(device = %path, "input device opened"); + backoff_s = 2; + read_events(&mut file, &tx, path); + warn!(device = %path, "lost connection to input device, retrying in {}s", backoff_s); + } + Err(e) => { + warn!(device = %path, error = %e, "cannot open input device, retrying in {}s", backoff_s); + } + } + std::thread::sleep(std::time::Duration::from_secs(backoff_s)); + backoff_s = (backoff_s * 2).min(60); + } +} + +/// Ouvre et lit un device une fois (sort en cas d'erreur). +fn device_loop_once(path: &str, tx: &UnboundedSender) { + match File::open(path) { + Ok(mut file) => { + read_events(&mut file, tx, path); + } + Err(e) => { + warn!(device = %path, error = %e, "cannot open input device"); + } + } +} + +/// Lit les evenements bruts depuis le fichier jusqu'a erreur. +fn read_events(file: &mut File, tx: &UnboundedSender, path: &str) { + let mut buf = [0u8; INPUT_EVENT_SIZE]; + loop { + if let Err(e) = file.read_exact(&mut buf) { + warn!(device = %path, error = %e, "read error on input device"); + return; + } + let ev_type = u16::from_ne_bytes([buf[16], buf[17]]); + let code = u16::from_ne_bytes([buf[18], buf[19]]); + let value = i32::from_ne_bytes([buf[20], buf[21], buf[22], buf[23]]); + + if ev_type == EV_KEY && value == KEY_DOWN { + let name = key_name(code); + info!(device = %path, key = %name, code = code, "keycode: key pressed"); + let _ = tx.send(name); + } + } +} + +/// Convertit un code Linux en nom lisible. +pub fn key_name(code: u16) -> String { + let name = match code { + 1 => "KEY_ESC", + 2 => "KEY_1", 3 => "KEY_2", 4 => "KEY_3", 5 => "KEY_4", + 6 => "KEY_5", 7 => "KEY_6", 8 => "KEY_7", 9 => "KEY_8", + 10 => "KEY_9", 11 => "KEY_0", + 14 => "KEY_BACKSPACE", + 15 => "KEY_TAB", + 28 => "KEY_ENTER", + 57 => "KEY_SPACE", + 102 => "KEY_HOME", + 103 => "KEY_UP", + 104 => "KEY_PAGEUP", + 105 => "KEY_LEFT", + 106 => "KEY_RIGHT", + 107 => "KEY_END", + 108 => "KEY_DOWN", + 109 => "KEY_PAGEDOWN", + 110 => "KEY_INSERT", + 111 => "KEY_DELETE", + 113 => "KEY_MUTE", + 114 => "KEY_VOLUMEDOWN", + 115 => "KEY_VOLUMEUP", + 116 => "KEY_POWER", + 119 => "KEY_PAUSE", + 128 => "KEY_STOP", + 139 => "KEY_MENU", + 142 => "KEY_SLEEP", + 143 => "KEY_WAKEUP", + 158 => "KEY_BACK", + 159 => "KEY_FORWARD", + 163 => "KEY_NEXTSONG", + 164 => "KEY_PLAYPAUSE", + 165 => "KEY_PREVIOUSSONG", + 166 => "KEY_STOPCD", + 167 => "KEY_RECORD", + 168 => "KEY_REWIND", + 172 => "KEY_HOMEPAGE", // touche Home de telecommande + 173 => "KEY_REFRESH", + 174 => "KEY_EXIT", + // Navigation chaines TV + 402 => "KEY_CHANNELUP", + 403 => "KEY_CHANNELDOWN", + // Touches telecommande + 353 => "KEY_OK", + // Boutons gamepad/manette (BTN_*) + 304 => "BTN_A", + 305 => "BTN_B", + 306 => "BTN_C", + 307 => "BTN_X", + 308 => "BTN_Y", + 309 => "BTN_Z", + 310 => "BTN_TL", + 311 => "BTN_TR", + 312 => "BTN_TL2", + 313 => "BTN_TR2", + 314 => "BTN_SELECT", + 315 => "BTN_START", + 316 => "BTN_MODE", + 317 => "BTN_THUMBL", + 318 => "BTN_THUMBR", + 544 => "BTN_DPAD_UP", + 545 => "BTN_DPAD_DOWN", + 546 => "BTN_DPAD_LEFT", + 547 => "BTN_DPAD_RIGHT", + _ => return format!("KEY_{}", code), + }; + name.to_string() +} diff --git a/pilot-v2/src/lib.rs b/pilot-v2/src/lib.rs index 446cfb9..20c2495 100644 --- a/pilot-v2/src/lib.rs +++ b/pilot-v2/src/lib.rs @@ -2,6 +2,8 @@ pub mod config; pub mod mqtt; pub mod ha; +pub mod keycode; +pub mod m3u; pub mod telemetry; pub mod commands; pub mod platform; diff --git a/pilot-v2/src/m3u.rs b/pilot-v2/src/m3u.rs new file mode 100644 index 0000000..32fc216 --- /dev/null +++ b/pilot-v2/src/m3u.rs @@ -0,0 +1,60 @@ +// Parseur M3U simplifie pour les playlists IPTV. + +/// Retourne la liste des chaines sous forme (nom, url). +pub fn parse(content: &str) -> Vec<(String, String)> { + let mut channels = Vec::new(); + let mut pending_name: Option = None; + + for line in content.lines() { + let line = line.trim(); + if line.starts_with("#EXTINF:") { + // Le nom de la chaine est apres la derniere virgule de la ligne #EXTINF + if let Some(pos) = line.rfind(',') { + let name = line[pos + 1..].trim().to_string(); + if !name.is_empty() { + pending_name = Some(name); + } + } + } else if line.starts_with('#') || line.is_empty() { + // Ligne de commentaire ou vide — on ignore mais on garde pending_name + continue; + } else { + // Ligne URL + if let Some(name) = pending_name.take() { + channels.push((name, line.to_string())); + } + } + } + channels +} + +/// Charge et parse un fichier M3U. Retourne une liste vide en cas d'erreur. +pub fn parse_file(path: &str) -> Vec<(String, String)> { + match std::fs::read_to_string(path) { + Ok(content) => parse(&content), + Err(_) => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_basic() { + let content = "#EXTM3U\n#EXTINF:-1,Arte\nhttps://arte.example.com/live.m3u8\n#EXTINF:-1,BFM TV\nhttps://bfm.example.com/live.m3u8\n"; + let channels = parse(content); + assert_eq!(channels.len(), 2); + assert_eq!(channels[0].0, "Arte"); + assert_eq!(channels[1].0, "BFM TV"); + } + + #[test] + fn parse_with_vlcopt() { + let content = "#EXTINF:-1 tvg-id=\"test\",Arte\n#EXTVLCOPT:http-user-agent=Mozilla\nhttps://arte.example.com/live.m3u8\n"; + let channels = parse(content); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].0, "Arte"); + assert_eq!(channels[0].1, "https://arte.example.com/live.m3u8"); + } +} diff --git a/pilot-v2/src/platform/linux/mod.rs b/pilot-v2/src/platform/linux/mod.rs index be2af18..b8bf814 100644 --- a/pilot-v2/src/platform/linux/mod.rs +++ b/pilot-v2/src/platform/linux/mod.rs @@ -1,7 +1,8 @@ -// Implementations Linux (logind, sudoers, gnome busctl, x11 xset). +// Implementations Linux (logind, sudoers, gnome busctl, x11 xset, audio, apps, bluetooth). use anyhow::{bail, Context, Result}; use tracing::debug; use std::process::Command; +use std::path::Path; use crate::commands::{CommandAction, CommandValue}; @@ -12,13 +13,17 @@ pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> { CommandAction::Shutdown => run("systemctl", &["poweroff"]), CommandAction::Reboot => run("systemctl", &["reboot"]), CommandAction::Sleep => run("systemctl", &["suspend"]), + CommandAction::Hibernate => run("systemctl", &["hibernate"]), CommandAction::Screen => bail!("screen action not supported in power backend"), + _ => bail!("action not supported in power backend"), }, "linux_sudoers" => match action { CommandAction::Shutdown => run("shutdown", &["-h", "now"]), CommandAction::Reboot => run("reboot", &[]), CommandAction::Sleep => run("systemctl", &["suspend"]), + CommandAction::Hibernate => run("systemctl", &["hibernate"]), CommandAction::Screen => bail!("screen action not supported in power backend"), + _ => bail!("action not supported in power backend"), }, _ => bail!("unknown linux power backend"), } @@ -41,19 +46,35 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> { "1", ], ), - CommandValue::On => run( - "busctl", - &[ - "--user", - "set-property", - "org.gnome.Mutter.DisplayConfig", - "/org/gnome/Mutter/DisplayConfig", - "org.gnome.Mutter.DisplayConfig", - "PowerSaveMode", - "i", - "0", - ], - ), + CommandValue::On => { + // Retirer le mode veille ecran + run( + "busctl", + &[ + "--user", + "set-property", + "org.gnome.Mutter.DisplayConfig", + "/org/gnome/Mutter/DisplayConfig", + "org.gnome.Mutter.DisplayConfig", + "PowerSaveMode", + "i", + "0", + ], + )?; + // Simuler une activite utilisateur pour reveiller l'ecran + let _ = Command::new("busctl") + .args([ + "--user", + "call", + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + "org.freedesktop.ScreenSaver", + "SimulateUserActivity", + ]) + .output(); + Ok(()) + } + _ => bail!("unsupported value for screen"), }, "x11_xset" => match value { CommandValue::Off => { @@ -64,11 +85,212 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> { log_x11_env(); run("xset", &["dpms", "force", "on"]) } + _ => bail!("unsupported value for screen"), }, _ => bail!("unknown linux screen backend"), } } +// Regle le volume via wpctl (PipeWire). Volume en pourcentage 0-100. +pub fn execute_audio(volume: u8) -> Result<()> { + let level = format!("{:.2}", volume as f32 / 100.0); + debug!(volume = volume, level = %level, "setting volume via wpctl"); + let mut cmd = Command::new("wpctl"); + cmd.args(["set-volume", "@DEFAULT_AUDIO_SINK@", &level]); + inject_audio_env(&mut cmd); + let output = cmd.output().context("failed to run wpctl set-volume")?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("wpctl set-volume failed ({}): {}", output.status, stderr) + } +} + +// Lit le volume actuel via wpctl. Retourne 0-100 ou None en cas d'erreur. +pub fn read_volume() -> Option { + let mut cmd = Command::new("wpctl"); + cmd.args(["get-volume", "@DEFAULT_AUDIO_SINK@"]); + inject_audio_env(&mut cmd); + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + // Sortie format: "Volume: 0.60" ou "Volume: 0.60 [MUTED]" + let raw = String::from_utf8_lossy(&output.stdout); + let vol_str = raw.split_whitespace().nth(1)?; + let vol_f: f32 = vol_str.parse().ok()?; + Some((vol_f * 100.0).round() as u8) +} + +// Injecte XDG_RUNTIME_DIR/PIPEWIRE_RUNTIME_DIR pour les commandes audio. +// Necessaire quand pilot tourne sans ces variables d'environnement (service systeme). +fn inject_audio_env(cmd: &mut Command) { + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + cmd.env("XDG_RUNTIME_DIR", &xdg); + cmd.env("PIPEWIRE_RUNTIME_DIR", &xdg); + return; + } + // Fallback: chercher /run/user/ avec un socket pipewire + if let Ok(entries) = std::fs::read_dir("/run/user") { + for entry in entries.flatten() { + let dir = entry.path(); + if dir.join("pipewire-0").exists() || dir.join("wayland-0").exists() { + if let Some(dir_str) = dir.to_str() { + cmd.env("XDG_RUNTIME_DIR", dir_str); + cmd.env("PIPEWIRE_RUNTIME_DIR", dir_str); + return; + } + } + } + } +} + +// Demarre une application configuree en transmettant les variables d'environnement GUI. +pub fn execute_app_start(start_cmd: &str, start_args: &[String]) -> Result<()> { + debug!(cmd = %start_cmd, args = ?start_args, "starting app"); + let args_ref: Vec<&str> = start_args.iter().map(|s| s.as_str()).collect(); + let mut cmd = Command::new(start_cmd); + cmd.args(&args_ref); + + // Transmettre les variables X11/Wayland/DBus pour les apps graphiques. + // Si non definies (ex: service systemd), construire depuis XDG_RUNTIME_DIR. + for var in &[ + "DISPLAY", + "XAUTHORITY", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "XDG_SESSION_TYPE", + "HOME", + ] { + if let Ok(val) = std::env::var(var) { + cmd.env(var, val); + } + } + + // XAUTHORITY: toujours chercher le fichier mutter Xwayland, meme si DISPLAY est deja + // present dans l'environnement. Le suffix est aleatoire et change a chaque session, + // donc on ne peut pas le hardcoder dans le service systemd. + if std::env::var("XAUTHORITY").is_err() { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| "/run/user/1000".to_string()); + let runtime_path = Path::new(&runtime_dir); + + // Fallback complet si WAYLAND_DISPLAY aussi absent + if std::env::var("WAYLAND_DISPLAY").is_err() && runtime_path.join("wayland-0").exists() { + cmd.env("XDG_RUNTIME_DIR", &runtime_dir); + cmd.env("WAYLAND_DISPLAY", "wayland-0"); + cmd.env("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}/bus", runtime_dir)); + } + + // Injecter DISPLAY + XAUTHORITY pour XWayland (apps Electron/Flatpak) + if let Ok(auth) = find_mutter_xauth(runtime_path) { + cmd.env("DISPLAY", ":0"); + cmd.env("XAUTHORITY", &auth); + debug!(xauthority = %auth.display(), "xwayland auth injected"); + } else { + cmd.env("DISPLAY", ":0"); + debug!("DISPLAY=:0 injected (no xauthority found)"); + } + } + + cmd.spawn() + .with_context(|| format!("failed to start {start_cmd}"))?; + Ok(()) +} + +// Arrete une application via pkill -9 (SIGKILL) pour garantir l'arret immediat. +pub fn execute_app_stop(process_check: &str) -> Result<()> { + debug!(process = %process_check, "stopping app via pkill -9"); + Command::new("pkill") + .args(["-9", "-i", "-f", process_check]) + .status() + .with_context(|| format!("failed to run pkill for {process_check}"))?; + Ok(()) +} + +// Verifie si une application est en cours d'execution via pgrep. +pub fn app_is_running(process_check: &str) -> bool { + Command::new("pgrep") + .args(["-f", process_check]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +// Connecte ou deconnecte un appareil Bluetooth via bluetoothctl. +pub fn execute_bluetooth(mac: &str, connect: bool) -> Result<()> { + let action = if connect { "connect" } else { "disconnect" }; + debug!(mac = %mac, action = %action, "bluetooth command"); + run("bluetoothctl", &[action, mac]) +} + +// Verifie si un appareil Bluetooth est connecte via bluetoothctl info. +pub fn bluetooth_is_connected(mac: &str) -> bool { + let output = Command::new("bluetoothctl") + .args(["info", mac]) + .output(); + match output { + Ok(out) => { + let text = String::from_utf8_lossy(&out.stdout); + text.lines() + .any(|line| line.trim().starts_with("Connected:") && line.contains("yes")) + } + Err(_) => false, + } +} + +const SLEEP_INHIBIT_PID_FILE: &str = "/tmp/pilot-sleep-inhibit.pid"; + +// Active ou desactive l'inhibition de mise en veille via systemd-inhibit. +pub fn execute_inhibit_sleep(enable: bool) -> Result<()> { + if enable { + let child = Command::new("systemd-inhibit") + .args([ + "--what=sleep:idle", + "--who=Pilot", + "--why=HomeAssistant inhibit", + "--mode=block", + "sleep", "infinity", + ]) + .spawn() + .context("failed to start systemd-inhibit")?; + let pid = child.id(); + std::fs::write(SLEEP_INHIBIT_PID_FILE, pid.to_string()) + .context("failed to write inhibit PID file")?; + debug!(pid = pid, "sleep inhibitor started"); + } else { + if let Ok(pid_str) = std::fs::read_to_string(SLEEP_INHIBIT_PID_FILE) { + if let Ok(pid) = pid_str.trim().parse::() { + run("kill", &[&pid.to_string()])?; + } + } + let _ = std::fs::remove_file(SLEEP_INHIBIT_PID_FILE); + debug!("sleep inhibitor stopped"); + } + Ok(()) +} + +// Verifie si l'inhibition de veille est active. +pub fn is_sleep_inhibited() -> bool { + if let Ok(pid_str) = std::fs::read_to_string(SLEEP_INHIBIT_PID_FILE) { + if let Ok(pid) = pid_str.trim().parse::() { + return Path::new(&format!("/proc/{}", pid)).exists(); + } + } + false +} + +// Lance la mise a jour systeme via apt (sudo apt update && apt upgrade -y). +// Necessite une entree sudoers NOPASSWD pour /usr/bin/apt. +pub fn execute_system_update() -> Result<()> { + debug!("running apt update"); + run("sudo", &["apt", "update"])?; + debug!("running apt upgrade"); + run("sudo", &["apt", "upgrade", "-y"]) +} + fn run(cmd: &str, args: &[&str]) -> Result<()> { debug!(%cmd, args = ?args, "running command"); let output = Command::new(cmd) @@ -83,6 +305,20 @@ fn run(cmd: &str, args: &[&str]) -> Result<()> { } } +// Cherche le fichier .mutter-Xwaylandauth.* dans le runtime dir pour XWayland. +fn find_mutter_xauth(runtime_dir: &Path) -> Result { + let entries = std::fs::read_dir(runtime_dir) + .context("read runtime dir")?; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(".mutter-Xwaylandauth") { + return Ok(entry.path()); + } + } + anyhow::bail!("mutter Xwayland auth file not found") +} + fn log_x11_env() { let display_env = std::env::var("DISPLAY").unwrap_or_default(); let xauth_env = std::env::var("XAUTHORITY").unwrap_or_default(); diff --git a/pilot-v2/src/platform/mod.rs b/pilot-v2/src/platform/mod.rs index 4dd4ffc..f740c3d 100644 --- a/pilot-v2/src/platform/mod.rs +++ b/pilot-v2/src/platform/mod.rs @@ -7,7 +7,7 @@ use crate::commands::{CommandAction, CommandValue}; pub mod linux; pub mod windows; -// Execute une commande d'alimentation (shutdown/reboot/sleep). +// Execute une commande d'alimentation (shutdown/reboot/sleep/hibernate). pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> { if cfg!(target_os = "windows") { windows::execute_power(backend, action) @@ -24,3 +24,93 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> { linux::execute_screen(backend, value) } } + +// Regle le volume systeme (0-100 via wpctl). +pub fn execute_audio(volume: u8) -> Result<()> { + if cfg!(target_os = "windows") { + anyhow::bail!("audio control not implemented on Windows") + } else { + linux::execute_audio(volume) + } +} + +// Lit le volume actuel (0-100). +pub fn read_volume() -> Option { + if cfg!(target_os = "windows") { + None + } else { + linux::read_volume() + } +} + +// Demarre une application. +pub fn execute_app_start(start_cmd: &str, start_args: &[String]) -> Result<()> { + if cfg!(target_os = "windows") { + anyhow::bail!("app control not implemented on Windows") + } else { + linux::execute_app_start(start_cmd, start_args) + } +} + +// Arrete une application via son nom de processus. +pub fn execute_app_stop(process_check: &str) -> Result<()> { + if cfg!(target_os = "windows") { + anyhow::bail!("app control not implemented on Windows") + } else { + linux::execute_app_stop(process_check) + } +} + +// Verifie si une application est en cours d'execution. +pub fn app_is_running(process_check: &str) -> bool { + if cfg!(target_os = "windows") { + false + } else { + linux::app_is_running(process_check) + } +} + +// Connecte ou deconnecte un appareil Bluetooth. +pub fn execute_bluetooth(mac: &str, connect: bool) -> Result<()> { + if cfg!(target_os = "windows") { + anyhow::bail!("bluetooth control not implemented on Windows") + } else { + linux::execute_bluetooth(mac, connect) + } +} + +// Verifie si un appareil Bluetooth est connecte. +pub fn bluetooth_is_connected(mac: &str) -> bool { + if cfg!(target_os = "windows") { + false + } else { + linux::bluetooth_is_connected(mac) + } +} + +// Active/desactive l'inhibition de veille. +pub fn execute_inhibit_sleep(enable: bool) -> Result<()> { + if cfg!(target_os = "windows") { + anyhow::bail!("sleep inhibit not implemented on Windows") + } else { + linux::execute_inhibit_sleep(enable) + } +} + +// Verifie si la veille est inhibee. +pub fn is_sleep_inhibited() -> bool { + if cfg!(target_os = "windows") { + false + } else { + linux::is_sleep_inhibited() + } +} + +// Lance la mise a jour systeme. +pub fn execute_system_update() -> Result<()> { + if cfg!(target_os = "windows") { + anyhow::bail!("system update not implemented on Windows") + } else { + linux::execute_system_update() + } +} diff --git a/pilot-v2/src/runtime/mod.rs b/pilot-v2/src/runtime/mod.rs index 22f9ef4..dea81b4 100644 --- a/pilot-v2/src/runtime/mod.rs +++ b/pilot-v2/src/runtime/mod.rs @@ -116,6 +116,27 @@ impl Runtime { mqtt::subscribe_commands(&client, &self.config).await?; } + // Charger les chaines M3U pour les apps avec channels_m3u + let mut channels_map: HashMap> = HashMap::new(); + for app in &self.config.apps { + if let Some(m3u_path) = &app.channels_m3u { + let channels = crate::m3u::parse_file(m3u_path); + if !channels.is_empty() { + info!(app = %app.name, count = channels.len(), m3u = %m3u_path, "loaded M3U channels"); + channels_map.insert(app.name.clone(), channels); + } else { + warn!(app = %app.name, m3u = %m3u_path, "M3U file empty or not found"); + } + } + } + let mut current_channels: HashMap = HashMap::new(); + + // Demarrer le listener de touches si configure + let (keycode_tx, mut keycode_rx) = tokio::sync::mpsc::unbounded_channel::(); + if self.config.keycodes.enabled && !self.config.keycodes.devices.is_empty() { + crate::keycode::start_listener(self.config.keycodes.devices.clone(), keycode_tx); + } + tracing::info!("entering main event loop"); let mut telemetry = if self.config.features.telemetry.enabled { @@ -128,7 +149,28 @@ impl Runtime { self.config.publish.heartbeat_s, )); let mut stats_tick = interval(Duration::from_secs(60)); + // Ticks rapides pour volume (2s) et etat des apps (2s) + let mut volume_tick = interval(Duration::from_secs(2)); + let mut app_state_tick = interval(Duration::from_secs(2)); + // Sync lent (30s) : pgrep pour detecter les apps demarrees/arretees en dehors de pilot + let mut app_sync_tick = interval(Duration::from_secs(30)); let mut last_exec: HashMap = HashMap::new(); + // Etat interne des apps: pilot suit lui-meme l'etat ON/OFF des apps qu'il gere. + // Cela evite le probleme de race condition avec pgrep apres un stop SIGKILL. + // Un sync lent (30s) via pgrep permet de detecter les apps demarrees/arretees en dehors de pilot. + let mut app_states: HashMap = HashMap::new(); + for app in &self.config.apps { + if app.enabled { + app_states.insert(app.name.clone(), platform::app_is_running(&app.process_check)); + } + } + + // Detection double appui sur les touches liees (key_bindings). + // pending_key = (nom_touche, index_binding) en attente de confirmation single/double. + let mut pending_key: Option<(String, usize)> = None; + // Timer remis a zero a chaque premier appui — expire => single press. + let key_press_timer = tokio::time::sleep(tokio::time::Duration::from_millis(u64::MAX)); + tokio::pin!(key_press_timer); let shutdown = tokio::signal::ctrl_c(); tokio::pin!(shutdown); @@ -149,19 +191,33 @@ impl Runtime { } if !due.is_empty() { + // Metriques speciales gerees directement dans le runtime let power_state_due = due.remove("power_state"); + let volume_due = due.remove("volume_level"); + + // Collecte et publie les metriques standard let metrics = telemetry.as_mut().unwrap().read(&due); for (name, value) in metrics { if let Err(err) = mqtt::publish_state(&client, &self.config, &name, &value).await { warn!(error = %err, "publish state failed"); } } + if power_state_due && enabled_metrics.contains("power_state") { let current = detect_power_state(); if let Err(err) = mqtt::publish_state(&client, &self.config, "power_state", ¤t).await { warn!(error = %err, "publish power_state failed"); } } + + // Volume via wpctl (capteur telemetrie) + if volume_due && enabled_metrics.contains("volume_level") { + if let Some(vol) = platform::read_volume() { + if let Err(err) = mqtt::publish_state(&client, &self.config, "volume_level", &vol.to_string()).await { + warn!(error = %err, "publish volume_level failed"); + } + } + } } } _ = heartbeat_tick.tick() => { @@ -169,6 +225,28 @@ impl Runtime { if let Err(err) = mqtt::publish_status(&client, &self.config, &status).await { warn!(error = %err, "publish status failed"); } + // Bluetooth uniquement dans le heartbeat (moins critique) + update_bluetooth_states(&client, &self.config).await; + } + _ = volume_tick.tick() => { + // Actualisation rapide du volume (5s) pour reflechir les changements locaux + if let Some(vol) = platform::read_volume() { + let vol_str = vol.to_string(); + let _ = mqtt::publish_state(&client, &self.config, "volume_level", &vol_str).await; + let _ = mqtt::publish_switch_state(&client, &self.config, "volume", &vol_str).await; + } + } + _ = app_state_tick.tick() => { + publish_app_states(&client, &self.config, &app_states).await; + } + _ = app_sync_tick.tick() => { + // Sync pgrep pour detecter les changements exterieurs + for app in &self.config.apps { + if app.enabled { + let running = platform::app_is_running(&app.process_check); + app_states.insert(app.name.clone(), running); + } + } } _ = stats_tick.tick() => { let published = mqtt::take_publish_count(); @@ -181,12 +259,84 @@ impl Runtime { &client, &self.config, &mut last_exec, + &channels_map, + &mut current_channels, + &mut app_states, &topic, &payload, ).await { warn!(error = %err, "command handling failed"); } } + // Timer double appui expire → single press confirme + _ = &mut key_press_timer, if pending_key.is_some() => { + if let Some((key, idx)) = pending_key.take() { + let binding = &self.config.key_bindings.bindings[idx]; + if let Some(action) = &binding.single_press { + execute_key_binding_action(&client, &self.config, &mut app_states, action).await; + info!(key = %key, action = %action, "key binding: single press"); + } + } + } + Some(key) = keycode_rx.recv() => { + info!(key = %key, "keycode received"); + let _ = mqtt::publish_state(&client, &self.config, "keycode", &key).await; + + // Detecter si cette touche est liee a un binding + let binding_idx = if self.config.key_bindings.enabled { + let idx = self.config.key_bindings.bindings.iter().position(|b| b.key == key); + info!(key = %key, found = idx.is_some(), "keycode: binding lookup"); + idx + } else { + info!("keycode: key_bindings disabled"); + None + }; + + if let Some(idx) = binding_idx { + let binding = &self.config.key_bindings.bindings[idx]; + if let Some((ref pk, _)) = pending_key { + if *pk == key { + // Double appui detecte + pending_key = None; + let action = binding.double_press.clone(); + info!(key = %key, action = ?action, "key binding: DOUBLE PRESS detected"); + if let Some(action) = action { + execute_key_binding_action(&client, &self.config, &mut app_states, &action).await; + } + } else { + // Touche differente : fire single press de l'ancienne touche + let (old_key, old_idx) = pending_key.take().unwrap(); + let old_binding = &self.config.key_bindings.bindings[old_idx]; + if let Some(action) = old_binding.single_press.clone() { + info!(key = %old_key, action = %action, "key binding: single press (interrupted by other key)"); + execute_key_binding_action(&client, &self.config, &mut app_states, &action).await; + } + // Armer le nouveau pending + pending_key = Some((key.clone(), idx)); + let deadline = tokio::time::Instant::now() + + tokio::time::Duration::from_millis(binding.double_press_ms); + key_press_timer.as_mut().reset(deadline); + info!(key = %key, ms = binding.double_press_ms, "key binding: first press, timer armed"); + } + } else { + // Premier appui : armer le timer + pending_key = Some((key.clone(), idx)); + let deadline = tokio::time::Instant::now() + + tokio::time::Duration::from_millis(binding.double_press_ms); + key_press_timer.as_mut().reset(deadline); + info!(key = %key, ms = binding.double_press_ms, "key binding: first press, timer armed"); + } + } else { + // Touche non liee : navigation chaines + handle_channel_nav_key( + &client, + &self.config, + &channels_map, + &mut current_channels, + &key, + ).await; + } + } _ = &mut shutdown => { if let Err(err) = mqtt::publish_availability(&client, &self.config, false).await { warn!(error = %err, "publish availability offline failed"); @@ -258,10 +408,42 @@ fn capabilities(cfg: &Config) -> Capabilities { let mut commands = Vec::new(); if cfg.features.commands.enabled { - commands.push("shutdown".to_string()); - commands.push("reboot".to_string()); - commands.push("sleep".to_string()); - commands.push("screen".to_string()); + let base_cmds = ["shutdown", "reboot", "sleep", "hibernate", "screen", "volume", "system_update"]; + for cmd in &base_cmds { + if cfg.features.commands.allowlist.is_empty() + || cfg.features.commands.allowlist.iter().any(|a| a == *cmd) + { + commands.push(cmd.to_string()); + } + } + for app in &cfg.apps { + if app.enabled { + let key = format!("app_{}", app.name); + if cfg.features.commands.allowlist.is_empty() + || cfg.features.commands.allowlist.iter().any(|a| a == &key) + { + commands.push(key); + } + if app.channels_m3u.is_some() { + let channel_key = format!("{}_channel", app.name); + if cfg.features.commands.allowlist.is_empty() + || cfg.features.commands.allowlist.iter().any(|a| a == &channel_key) + { + commands.push(channel_key); + } + } + } + } + if cfg.bluetooth.enabled { + for dev in &cfg.bluetooth.devices { + let key = format!("bluetooth_{}", dev.name); + if cfg.features.commands.allowlist.is_empty() + || cfg.features.commands.allowlist.iter().any(|a| a == &key) + { + commands.push(key); + } + } + } } Capabilities { @@ -346,11 +528,15 @@ fn detect_power_state_logind() -> Option { } None } + // Traite une commande entrante (topic + payload) avec cooldown et dry-run. async fn handle_command( client: &rumqttc::AsyncClient, cfg: &Config, last_exec: &mut HashMap, + channels_map: &HashMap>, + current_channels: &mut HashMap, + app_states: &mut HashMap, topic: &str, payload: &[u8], ) -> anyhow::Result<()> { @@ -358,72 +544,368 @@ async fn handle_command( let value = commands::parse_value(payload)?; debug!(%topic, ?action, ?value, "command received"); - if !commands::allowlist_allows(&cfg.features.commands.allowlist, action) { + if !commands::allowlist_allows(&cfg.features.commands.allowlist, &action) { return Ok(()); } - if !commands::allow_command(last_exec, cfg.features.commands.cooldown_s, action) { + if !commands::allow_command(last_exec, cfg.features.commands.cooldown_s, action.clone()) { return Ok(()); } if cfg.features.commands.dry_run { - commands::execute_dry_run(action, value)?; - publish_command_state(client, cfg, action, value).await?; + commands::execute_dry_run(&action, &value)?; + publish_command_state(client, cfg, &action, &value).await?; return Ok(()); } - match action { + match &action { CommandAction::Shutdown => { - if matches!(value, CommandValue::Off) { - platform::execute_power(&backend_power(cfg), action)?; + if matches!(value, CommandValue::On) { + // Reset immediat avant execution (la machine va s'eteindre) + mqtt::publish_switch_state(client, cfg, "shutdown", "OFF").await?; mqtt::publish_state(client, cfg, "power_state", "off").await?; - publish_command_state(client, cfg, action, value).await?; + platform::execute_power(&backend_power(cfg), action.clone())?; } } CommandAction::Reboot => { - if matches!(value, CommandValue::Off) { - platform::execute_power(&backend_power(cfg), action)?; + if matches!(value, CommandValue::On) { + mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await?; mqtt::publish_state(client, cfg, "power_state", "on").await?; - publish_command_state(client, cfg, action, value).await?; + platform::execute_power(&backend_power(cfg), action.clone())?; } } CommandAction::Sleep => { - if matches!(value, CommandValue::Off) { - platform::execute_power(&backend_power(cfg), action)?; + if matches!(value, CommandValue::On) { + mqtt::publish_switch_state(client, cfg, "sleep", "OFF").await?; mqtt::publish_state(client, cfg, "power_state", "sleep").await?; - publish_command_state(client, cfg, action, value).await?; + platform::execute_power(&backend_power(cfg), action.clone())?; + } + } + CommandAction::Hibernate => { + if matches!(value, CommandValue::On) { + mqtt::publish_switch_state(client, cfg, "hibernate", "OFF").await?; + mqtt::publish_state(client, cfg, "power_state", "sleep").await?; + platform::execute_power(&backend_power(cfg), action.clone())?; } } CommandAction::Screen => { let backend = backend_screen(cfg); debug!(backend = %backend, ?value, "executing screen command"); - platform::execute_screen(&backend, value)?; - publish_command_state(client, cfg, action, value).await?; + platform::execute_screen(&backend, value.clone())?; + publish_command_state(client, cfg, &action, &value).await?; + } + CommandAction::Volume => { + if let CommandValue::Number(vol) = value { + platform::execute_audio(vol)?; + mqtt::publish_switch_state(client, cfg, "volume", &vol.to_string()).await?; + } + } + CommandAction::App(name) => { + let app = cfg.apps.iter().find(|a| a.name == *name && a.enabled); + if let Some(app) = app { + let key = format!("app_{}", name); + match value { + CommandValue::On => { + platform::execute_app_start(&app.start_cmd, &app.start_args)?; + app_states.insert(name.clone(), true); + mqtt::publish_switch_state(client, cfg, &key, "ON").await?; + } + CommandValue::Off => { + platform::execute_app_stop(&app.process_check)?; + app_states.insert(name.clone(), false); + mqtt::publish_switch_state(client, cfg, &key, "OFF").await?; + } + _ => {} + } + } + } + CommandAction::Bluetooth(name) => { + if cfg.bluetooth.enabled { + let dev = cfg.bluetooth.devices.iter().find(|d| d.name == *name); + if let Some(dev) = dev { + let connect = matches!(value, CommandValue::On); + platform::execute_bluetooth(&dev.mac, connect)?; + let state = if platform::bluetooth_is_connected(&dev.mac) { "ON" } else { "OFF" }; + let key = format!("bluetooth_{}", name); + mqtt::publish_switch_state(client, cfg, &key, state).await?; + } + } + } + CommandAction::SystemUpdate => { + if matches!(value, CommandValue::On) { + platform::execute_system_update()?; + mqtt::publish_switch_state(client, cfg, "system_update", "OFF").await?; + } + } + CommandAction::InhibitSleep => { + let enable = matches!(value, CommandValue::On); + platform::execute_inhibit_sleep(enable)?; + let state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" }; + mqtt::publish_switch_state(client, cfg, "inhibit_sleep", state).await?; + } + CommandAction::TvChannel(app_name) => { + if let CommandValue::Text(channel_name) = &value { + if let Some(app) = cfg.apps.iter().find(|a| a.name == *app_name && a.enabled) { + if let Some(m3u_path) = &app.channels_m3u { + if let Some(channels) = channels_map.get(app_name.as_str()) { + if let Some((_, url)) = channels.iter().find(|(n, _)| n == channel_name) { + let url = url.clone(); + // Construire les args sans le chemin M3U, ajouter l'URL de la chaine + let filtered_args: Vec = app.start_args.iter() + .filter(|a| a.as_str() != m3u_path.as_str()) + .cloned() + .collect(); + let mut channel_args = filtered_args; + channel_args.push(url); + // Arreter l'instance existante, relancer sur la nouvelle chaine + platform::execute_app_stop(&app.process_check)?; + tokio::time::sleep(Duration::from_millis(500)).await; + platform::execute_app_start(&app.start_cmd, &channel_args)?; + let channel_key = format!("{}_channel", app_name); + mqtt::publish_switch_state(client, cfg, &channel_key, channel_name).await?; + let app_key = format!("app_{}", app_name); + mqtt::publish_switch_state(client, cfg, &app_key, "ON").await?; + current_channels.insert(app_name.clone(), channel_name.clone()); + } else { + warn!(channel = %channel_name, "channel not found in M3U"); + } + } + } + } + } } } Ok(()) } -// Publie l'etat initial des switches HA (par defaut ON). +// Publie l'etat initial des switches HA. +// Commandes momentanees (shutdown/reboot/sleep/hibernate/system_update) : OFF par defaut. +// Commandes avec etat reel (screen, inhibit_sleep, apps, bluetooth) : lues depuis le systeme. async fn publish_initial_command_states(client: &rumqttc::AsyncClient, cfg: &Config) { - let _ = mqtt::publish_switch_state(client, cfg, "shutdown", "ON").await; - let _ = mqtt::publish_switch_state(client, cfg, "reboot", "ON").await; - let _ = mqtt::publish_switch_state(client, cfg, "sleep", "ON").await; + let _ = mqtt::publish_switch_state(client, cfg, "shutdown", "OFF").await; + let _ = mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await; + let _ = mqtt::publish_switch_state(client, cfg, "sleep", "OFF").await; + let _ = mqtt::publish_switch_state(client, cfg, "hibernate", "OFF").await; let _ = mqtt::publish_switch_state(client, cfg, "screen", "ON").await; + let _ = mqtt::publish_switch_state(client, cfg, "system_update", "OFF").await; + let inhibit_state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" }; + let _ = mqtt::publish_switch_state(client, cfg, "inhibit_sleep", inhibit_state).await; + + // Volume initial + if let Some(vol) = platform::read_volume() { + let _ = mqtt::publish_switch_state(client, cfg, "volume", &vol.to_string()).await; + } + + // Etat initial des apps + for app in &cfg.apps { + if !app.enabled { continue; } + let running = platform::app_is_running(&app.process_check); + let state = if running { "ON" } else { "OFF" }; + let key = format!("app_{}", app.name); + let _ = mqtt::publish_switch_state(client, cfg, &key, state).await; + } + + // Etat initial Bluetooth + if cfg.bluetooth.enabled { + for dev in &cfg.bluetooth.devices { + let connected = platform::bluetooth_is_connected(&dev.mac); + let state = if connected { "ON" } else { "OFF" }; + let key = format!("bluetooth_{}", dev.name); + let _ = mqtt::publish_switch_state(client, cfg, &key, state).await; + } + } +} + +// Publie l'etat des apps depuis l'etat interne (pas de pgrep — evite les races conditions). +// L'etat interne est mis a jour quand pilot demarre/arrete une app. +// Un sync pgrep toutes les 30s detecte les changements exterieurs. +async fn publish_app_states( + client: &rumqttc::AsyncClient, + cfg: &Config, + app_states: &HashMap, +) { + for app in &cfg.apps { + if !app.enabled { + continue; + } + let key = format!("app_{}", app.name); + if !cfg.features.commands.allowlist.is_empty() + && !cfg.features.commands.allowlist.iter().any(|a| a == &key) + { + continue; + } + let running = app_states.get(&app.name).copied().unwrap_or(false); + let state = if running { "ON" } else { "OFF" }; + let _ = mqtt::publish_switch_state(client, cfg, &key, state).await; + } + + // Etat inhibition veille + let inhibit_state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" }; + let _ = mqtt::publish_switch_state(client, cfg, "inhibit_sleep", inhibit_state).await; +} + +// Verifie et publie l'etat Bluetooth (appelee au heartbeat uniquement — plus lent). +async fn update_bluetooth_states(client: &rumqttc::AsyncClient, cfg: &Config) { + if !cfg.bluetooth.enabled { + return; + } + for dev in &cfg.bluetooth.devices { + let key = format!("bluetooth_{}", dev.name); + if !cfg.features.commands.allowlist.is_empty() + && !cfg.features.commands.allowlist.iter().any(|a| a == &key) + { + continue; + } + let connected = platform::bluetooth_is_connected(&dev.mac); + let state = if connected { "ON" } else { "OFF" }; + let _ = mqtt::publish_switch_state(client, cfg, &key, state).await; + } +} + +// Execute l'action d'un key binding. +// Actions supportees : +// "vacuum_tube", "livebox_tv", ... → toggle app (via etat interne app_states) +// "key:28" → injecte le keycode 28 (Enter) via ydotool +// Met a jour app_states directement (pas de retour). +async fn execute_key_binding_action( + client: &rumqttc::AsyncClient, + cfg: &Config, + app_states: &mut HashMap, + action: &str, +) { + // Action "key:" : injection de touche via ydotool + if let Some(code_str) = action.strip_prefix("key:") { + info!(action = %action, "key binding: injecting key via ydotool"); + let result = std::process::Command::new("ydotool") + .args(["key", code_str]) + .output(); + match result { + Ok(out) if out.status.success() => {} + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + warn!(code = %code_str, stderr = %stderr, "ydotool key failed"); + } + Err(e) => { + warn!(code = %code_str, error = %e, "ydotool not available"); + } + } + return; + } + + if let Some(app) = cfg.apps.iter().find(|a| a.name == action && a.enabled) { + let key = format!("app_{}", action); + // Utilise l'etat interne pour eviter les races conditions avec pgrep + let is_running = app_states.get(action).copied().unwrap_or(false); + if is_running { + info!(app = %action, "key binding: stopping app"); + if let Err(e) = platform::execute_app_stop(&app.process_check) { + warn!(app = %action, error = %e, "key binding: failed to stop app"); + } + app_states.insert(action.to_string(), false); + let _ = mqtt::publish_switch_state(client, cfg, &key, "OFF").await; + } else { + info!(app = %action, "key binding: starting app"); + match platform::execute_app_start(&app.start_cmd, &app.start_args) { + Ok(_) => { + app_states.insert(action.to_string(), true); + let _ = mqtt::publish_switch_state(client, cfg, &key, "ON").await; + } + Err(e) => { + warn!(app = %action, error = %e, "key binding: failed to start app"); + } + } + } + } else { + warn!(action = %action, "key binding: unknown app action"); + } +} + +// Navigation chaine via touche de telecommande (prog+/prog-). +// Cherche l'app dont channel_next_key ou channel_prev_key correspond a la touche recue, +// calcule le nouvel index (wrapping), relance le lecteur sur la nouvelle chaine. +async fn handle_channel_nav_key( + client: &rumqttc::AsyncClient, + cfg: &Config, + channels_map: &HashMap>, + current_channels: &mut HashMap, + key: &str, +) { + for app in &cfg.apps { + if !app.enabled { + continue; + } + let is_next = app.channel_next_key.as_deref().map_or(false, |k| k == key); + let is_prev = app.channel_prev_key.as_deref().map_or(false, |k| k == key); + if !is_next && !is_prev { + continue; + } + let channels = match channels_map.get(&app.name) { + Some(c) if !c.is_empty() => c, + _ => continue, + }; + if !platform::app_is_running(&app.process_check) { + continue; + } + + let current = current_channels.get(&app.name).cloned(); + let idx = current + .as_deref() + .and_then(|name| channels.iter().position(|(n, _)| n == name)) + .unwrap_or(0); + let new_idx = if is_next { + (idx + 1) % channels.len() + } else if idx == 0 { + channels.len() - 1 + } else { + idx - 1 + }; + + let (new_channel_name, new_url) = &channels[new_idx]; + let url = new_url.clone(); + let new_channel_name = new_channel_name.clone(); + + // Reconstruire les args sans le chemin M3U, ajouter l'URL directe + let filtered_args: Vec = if let Some(m3u_path) = &app.channels_m3u { + app.start_args.iter().filter(|a| a.as_str() != m3u_path.as_str()).cloned().collect() + } else { + app.start_args.clone() + }; + let mut channel_args = filtered_args; + channel_args.push(url); + + if let Err(e) = platform::execute_app_stop(&app.process_check) { + warn!(error = %e, "channel nav: failed to stop app"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + if let Err(e) = platform::execute_app_start(&app.start_cmd, &channel_args) { + warn!(error = %e, "channel nav: failed to start app"); + continue; + } + + let channel_key = format!("{}_channel", app.name); + let app_key = format!("app_{}", app.name); + let _ = mqtt::publish_switch_state(client, cfg, &channel_key, &new_channel_name).await; + let _ = mqtt::publish_switch_state(client, cfg, &app_key, "ON").await; + current_channels.insert(app.name.clone(), new_channel_name.clone()); + info!(app = %app.name, channel = %new_channel_name, direction = if is_next { "next" } else { "prev" }, "channel navigation"); + break; + } } // Publie l'etat d'une commande pour Home Assistant. async fn publish_command_state( client: &rumqttc::AsyncClient, cfg: &Config, - action: CommandAction, - value: CommandValue, + action: &CommandAction, + value: &CommandValue, ) -> anyhow::Result<()> { let state = match value { - CommandValue::On => "ON", - CommandValue::Off => "OFF", + CommandValue::On => "ON".to_string(), + CommandValue::Off => "OFF".to_string(), + CommandValue::Number(n) => n.to_string(), + CommandValue::Text(s) => s.clone(), }; let name = commands::action_name(action); - mqtt::publish_switch_state(client, cfg, name, state).await + mqtt::publish_switch_state(client, cfg, &name, &state).await } diff --git a/scripts/install_pilot.sh b/scripts/install_pilot.sh new file mode 100755 index 0000000..8271af7 --- /dev/null +++ b/scripts/install_pilot.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# Script d'installation de Pilot v2 +# Usage: +# bash install_pilot.sh # premiere installation (clone depuis Gitea) +# bash install_pilot.sh --update # mise a jour (git pull + recompile + relance) +# bash install_pilot.sh --local # installe depuis le depot local (dev sur la meme machine) +# +# Le service est installe en tant que service UTILISATEUR (systemctl --user). +# loginctl enable-linger permet le demarrage automatique au boot sans connexion graphique. +# +# Prerequis: +# - rustup / cargo (https://rustup.rs) +# - git +# - sudo (pour loginctl enable-linger et l'ajout au groupe input) + +set -euo pipefail + +# ---------- Configuration ---------- +GITEA_URL="https://gitea.maison43.duckdns.org/gilles/pilot" +INSTALL_DIR="/home/gilles/pilot" +SERVICE_NAME="pilot-v2" +SERVICE_DIR="$HOME/.config/systemd/user" +SERVICE_FILE="$SERVICE_DIR/${SERVICE_NAME}.service" +LOCAL_REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# Couleurs +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${BLUE}==> $*${NC}"; } + +# ---------- Arguments ---------- +MODE="install" +for arg in "$@"; do + case "$arg" in + --update) MODE="update" ;; + --local) MODE="local" ;; + esac +done + +# ---------- Prerequis ---------- +step "Verification des prerequis" +command -v git &>/dev/null || error "git non trouve. Installez-le: sudo apt install git" +command -v cargo &>/dev/null || error "cargo non trouve. Installez Rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" +info "git et cargo disponibles" + +# ---------- Sources : clone, pull ou copie locale ---------- +step "Preparation des sources" + +if [ "$MODE" = "local" ]; then + # Copie depuis le depot de developpement local + info "Synchronisation depuis le depot local: $LOCAL_REPO_DIR" + if [ ! -d "$INSTALL_DIR" ]; then + mkdir -p "$INSTALL_DIR" + fi + # Copie en preservant la config existante et en ignorant le dossier target + # On copie tout sauf target/ et le config.yaml de l'install (s'il existe) + ( + cd "$LOCAL_REPO_DIR" + find . -mindepth 1 \ + ! -path './pilot-v2/target*' \ + ! -path './.git*' \ + -print0 | tar --null -T - -cf - | tar -C "$INSTALL_DIR" -xf - \ + --exclude='./pilot-v2/config.yaml' 2>/dev/null || true + ) + # Copier le config.yaml seulement si absent + if [ ! -f "$INSTALL_DIR/pilot-v2/config.yaml" ] && [ -f "$LOCAL_REPO_DIR/pilot-v2/config.yaml" ]; then + cp "$LOCAL_REPO_DIR/pilot-v2/config.yaml" "$INSTALL_DIR/pilot-v2/config.yaml" + fi + info "Sources synchronisees dans $INSTALL_DIR" + +elif [ "$MODE" = "update" ]; then + [ -d "$INSTALL_DIR/.git" ] || error "$INSTALL_DIR n'est pas un depot git. Lancez l'installation complete d'abord." + info "Mise a jour depuis Gitea..." + git -C "$INSTALL_DIR" pull + info "Sources mises a jour" + +else + # Installation complete depuis Gitea + if [ -d "$INSTALL_DIR/.git" ]; then + warn "Depot deja present dans $INSTALL_DIR." + warn "Utilisez --update pour mettre a jour, ou --local pour copier depuis le depot dev." + read -rp "Continuer et mettre a jour le depot existant ? [o/N] " confirm + [[ "$confirm" =~ ^[oO]$ ]] || exit 0 + git -C "$INSTALL_DIR" pull + else + if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then + error "$INSTALL_DIR existe et n'est pas vide. Supprimez-le ou utilisez --local / --update." + fi + info "Clonage depuis $GITEA_URL..." + git clone "$GITEA_URL" "$INSTALL_DIR" + fi +fi + +# ---------- Compilation ---------- +step "Compilation du binaire (release)" +cargo build --release --manifest-path "$INSTALL_DIR/pilot-v2/Cargo.toml" +BINARY="$INSTALL_DIR/pilot-v2/target/release/pilot-v2" +info "Binaire: $BINARY" + +# ---------- Configuration ---------- +step "Configuration" +CONFIG_FILE="$INSTALL_DIR/pilot-v2/config.yaml" +EXAMPLE_FILE="$INSTALL_DIR/config/config.example.yaml" + +if [ ! -f "$CONFIG_FILE" ]; then + if [ -f "$EXAMPLE_FILE" ]; then + cp "$EXAMPLE_FILE" "$CONFIG_FILE" + warn "config.yaml cree depuis l'exemple. A editer avant le demarrage:" + warn " nano $CONFIG_FILE" + warn "Points cles a configurer:" + warn " - mqtt.host / username / password" + warn " - device.name (nom de la machine dans Home Assistant)" + warn " - bluetooth.devices[].mac (voir: bluetoothctl paired-devices)" + else + warn "Aucun exemple de config trouve. Creez $CONFIG_FILE manuellement." + fi +else + info "config.yaml existant conserve" +fi + +# Dossier iptv +if [ -d "$INSTALL_DIR/iptv" ]; then + info "Dossier iptv/ present: $INSTALL_DIR/iptv/" +fi + +# ---------- Groupe input (pour la telecommande evdev) ---------- +step "Permissions groupe input" +if groups "$USER" | grep -qw input; then + info "Utilisateur $USER deja dans le groupe input" +else + warn "Ajout de $USER au groupe input (necessaire pour la telecommande evdev)..." + sudo usermod -aG input "$USER" + warn "IMPORTANT: deconnectez-vous et reconnectez-vous pour que le groupe prenne effet." + warn "Ou relancez le service apres reconnexion: systemctl --user restart $SERVICE_NAME" +fi + +# ---------- Service systemd utilisateur ---------- +step "Installation du service systemd utilisateur" + +mkdir -p "$SERVICE_DIR" + +# Detecter l'UID pour XDG_RUNTIME_DIR +USER_ID=$(id -u) + +cat > "$SERVICE_FILE" </dev/null | grep -q "Linger=yes"; then + info "Linger deja actif pour $USER" +else + if loginctl enable-linger "$USER" 2>/dev/null; then + info "Linger active pour $USER — le service demarrera automatiquement au boot" + elif sudo loginctl enable-linger "$USER" 2>/dev/null; then + info "Linger active (via sudo) pour $USER" + else + warn "Impossible d'activer linger automatiquement." + warn "Activez-le manuellement: loginctl enable-linger $USER" + fi +fi + +# ---------- Activation et demarrage ---------- +step "Activation du service" +systemctl --user daemon-reload +systemctl --user enable "$SERVICE_NAME" +info "Service active au demarrage" + +# Verifier si la config a l'air configuree +CONFIG_READY=true +if [ -f "$CONFIG_FILE" ]; then + if grep -qE 'host:\s+""\s*$|host:\s*$' "$CONFIG_FILE" 2>/dev/null; then + CONFIG_READY=false + fi +fi + +if systemctl --user is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + info "Redemarrage du service..." + systemctl --user restart "$SERVICE_NAME" + sleep 2 + systemctl --user status "$SERVICE_NAME" --no-pager -l || true +elif $CONFIG_READY; then + info "Demarrage du service..." + systemctl --user start "$SERVICE_NAME" || true + sleep 2 + systemctl --user status "$SERVICE_NAME" --no-pager -l || true +else + warn "Config non configuree (mqtt.host vide). Service NON demarre." + warn "Editez $CONFIG_FILE puis lancez:" + warn " systemctl --user start $SERVICE_NAME" +fi + +# ---------- Resume ---------- +echo "" +echo "══════════════════════════════════════════════════" +info "Installation terminee!" +echo "" +echo " Repertoire : $INSTALL_DIR" +echo " Binaire : $BINARY" +echo " Config : $CONFIG_FILE" +echo " Service : $SERVICE_NAME (utilisateur)" +echo "" +echo " Commandes utiles:" +echo " systemctl --user status $SERVICE_NAME" +echo " systemctl --user start $SERVICE_NAME" +echo " systemctl --user stop $SERVICE_NAME" +echo " systemctl --user restart $SERVICE_NAME" +echo " journalctl --user -u $SERVICE_NAME -f" +echo "" +echo " Mise a jour:" +echo " bash $0 --update # depuis Gitea" +echo " bash $0 --local # depuis le depot dev local" +echo "" +echo " Trouver les MAC Bluetooth:" +echo " bluetoothctl paired-devices" +echo "══════════════════════════════════════════════════"