feat(yoga14): remote control, app management, install script

- Add keycode module: G7BTS Rii remote control support (evdev, auto-reconnect)
- Add key bindings: single/double press detection with configurable window
  - KEY_HOMEPAGE: single=VacuumTube, double=LiveboxTV
  - KEY_OK: inject Enter keypress via ydotool
  - KEY_PAGEUP/DOWN: LiveboxTV channel navigation
- Add M3U parser and channel selector for LiveboxTV (51 channels)
- Add volume entity (wpctl/PipeWire, 2s polling)
- Add app management: vacuum_tube, livebox_tv (start/stop/state via MQTT)
- Add grace period to prevent app state bounce after stop
- Fix screen ON via GNOME busctl: add SimulateUserActivity
- Fix power commands: trigger on ON, publish OFF immediately (momentary buttons)
- Disable GPU temp/usage entities
- Add install script: build, deploy to ~/pilot, systemd user service
- Fix service startup: WantedBy=graphical-session.target (full GNOME env at launch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 17:16:12 +01:00
parent 6c4c6ee866
commit ffabf65b35
17 changed files with 2226 additions and 252 deletions

16
amelioration.md Normal file
View File

@@ -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

View File

@@ -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

147
config/france_tv.m3u Normal file
View File

@@ -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

20
error.md Normal file
View File

@@ -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

127
iptv/france_tv.m3u Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/<action>/set.
@@ -41,28 +53,65 @@ pub fn parse_action(topic: &str) -> Result<CommandAction> {
"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<CommandValue> {
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::<u8>() {
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]

View File

@@ -15,6 +15,14 @@ pub struct Config {
pub screen_backend: ScreenBackend,
pub publish: Publish,
pub paths: Option<Paths>,
#[serde(default)]
pub apps: Vec<AppConfig>,
#[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<String>,
pub process_check: String,
/// Chemin vers un fichier M3U pour activer le selecteur de chaine (optionnel).
#[serde(default)]
pub channels_m3u: Option<String>,
/// Touche (nom keycode) pour passer a la chaine suivante.
#[serde(default)]
pub channel_next_key: Option<String>,
/// Touche (nom keycode) pour passer a la chaine precedente.
#[serde(default)]
pub channel_prev_key: Option<String>,
}
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<String>,
/// Action sur double appui.
#[serde(default)]
pub double_press: Option<String>,
/// 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<KeyBinding>,
}
// 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<String>,
}
// 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<BluetoothDevice>,
}
// Charge la config depuis les chemins par defaut (OS + fallback).
pub fn load() -> Result<Config> {
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);

View File

@@ -18,6 +18,7 @@ struct DeviceInfo {
suggested_area: Option<String>,
}
// Entite generique (sensor, switch).
#[derive(Serialize)]
struct EntityConfig {
name: String,
@@ -43,6 +44,42 @@ struct EntityConfig {
icon: Option<String>,
}
// 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<String>,
device: DeviceInfo,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
}
// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
}
// 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<String> = 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?;
}

197
pilot-v2/src/keycode.rs Normal file
View File

@@ -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<String>, tx: UnboundedSender<String>) {
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<String> {
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<String>) {
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<String>) {
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<String>) {
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<String>, 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()
}

View File

@@ -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;

60
pilot-v2/src/m3u.rs Normal file
View File

@@ -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<String> = 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");
}
}

View File

@@ -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<u8> {
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/<uid> 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::<u32>() {
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::<u32>() {
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<std::path::PathBuf> {
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();

View File

@@ -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<u8> {
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()
}
}

View File

@@ -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<String, Vec<(String, String)>> = 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<String, String> = HashMap::new();
// Demarrer le listener de touches si configure
let (keycode_tx, mut keycode_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
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<CommandAction, std::time::Instant> = 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<String, bool> = 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", &current).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<String> {
}
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<CommandAction, std::time::Instant>,
channels_map: &HashMap<String, Vec<(String, String)>>,
current_channels: &mut HashMap<String, String>,
app_states: &mut HashMap<String, bool>,
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<String> = 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<String, bool>,
) {
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<String, bool>,
action: &str,
) {
// Action "key:<code>" : 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<String, Vec<(String, String)>>,
current_channels: &mut HashMap<String, String>,
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<String> = 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
}

252
scripts/install_pilot.sh Executable file
View File

@@ -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" <<EOF
[Unit]
Description=Pilot v2 MQTT Agent
Documentation=https://gitea.maison43.duckdns.org/gilles/pilot
# Demarre apres la session graphique GNOME (env complet: WAYLAND_DISPLAY, XAUTHORITY, XDG_*)
After=graphical-session.target network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}/pilot-v2
ExecStart=${BINARY}
# Redemarrage automatique en cas d'erreur (5s entre les tentatives)
Restart=on-failure
RestartSec=5
# Variables d'environnement
Environment=RUST_LOG=info
# Arret propre: pilot publie "offline" sur MQTT avant de quitter
# SIGTERM → pilot capte ctrl_c() et publie LWT offline, 10s max
KillSignal=SIGTERM
TimeoutStopSec=10
[Install]
# graphical-session.target: demarre quand GNOME est pret (env de session complet)
# linger doit etre actif pour que ca fonctionne au boot sans intervention
WantedBy=graphical-session.target
EOF
info "Fichier service cree: $SERVICE_FILE"
# ---------- Linger : demarrage au boot sans connexion ----------
step "Activation du demarrage automatique au boot (linger)"
if loginctl show-user "$USER" 2>/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 "══════════════════════════════════════════════════"