Compare commits

1 Commits
main ... yoga14

Author SHA1 Message Date
ffabf65b35 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>
2026-03-15 17:16:12 +01:00
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: "" device_class: ""
icon: "mdi:desktop-classic" icon: "mdi:desktop-classic"
state_class: "" 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: commands:
enabled: true enabled: true
cooldown_s: 5 cooldown_s: 5
dry_run: true 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: power_backend:
linux: "linux_logind_polkit" # or linux_sudoers linux: "linux_logind_polkit" # or linux_sudoers
@@ -305,7 +328,40 @@ publish:
heartbeat_s: 30 heartbeat_s: 30
availability: true 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: paths:
linux_config: "/etc/pilot/config.yaml" linux_config: "/etc/pilot/config.yaml"
windows_config: "C:\\ProgramData\\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] [Unit]
Description=Pilot v2 MQTT Agent 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 Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=pilot WorkingDirectory=%h/pilot/pilot-v2
WorkingDirectory=/opt/pilot ExecStart=%h/pilot/pilot-v2/target/release/pilot-v2
ExecStart=/opt/pilot/pilot
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
Environment=RUST_LOG=info 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] [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: device:
name: $hostname name: $hostname
identifiers: ["$hostname"] identifiers: ["$hostname"]
manufacturer: "Asus" manufacturer: "Lenovo"
model: "Laptop" model: "Yoga 14"
sw_version: "2.0.0" sw_version: "2.0.0"
suggested_area: "Bureau" suggested_area: "Bureau"
mqtt: mqtt:
host: "10.0.0.3" host: "10.0.0.3" # <- adresse de ton serveur Home Assistant / broker Mosquitto
port: 1883 port: 1883
username: "" username: "" # <- si authentification activee sur Mosquitto
password: "" password: ""
base_topic: "pilot" base_topic: "pilot"
discovery_prefix: "homeassistant" discovery_prefix: "homeassistant"
client_id: "$hostname" client_id: "$hostname"
keepalive_s: 60 keepalive_s: 60
qos: 0 qos: 1
retain_states: true retain_states: true
reconnect:
attempts: 3
retry_delay_s: 1
short_wait_s: 60
long_wait_s: 3600
features: features:
telemetry: telemetry:
@@ -38,33 +35,13 @@ features:
device_class: "" device_class: ""
icon: "mdi:chip" icon: "mdi:chip"
state_class: "measurement" 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: cpu_temp_c:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 30 interval_s: 10
name: "CPU Temp" name: "CPU Temp"
unique_id: "$hostname_cpu_temp" unique_id: "$hostname_cpu_temp"
unit: "°C" unit: "C"
device_class: "temperature" device_class: "temperature"
icon: "mdi:thermometer" icon: "mdi:thermometer"
state_class: "measurement" state_class: "measurement"
@@ -74,114 +51,35 @@ features:
interval_s: 60 interval_s: 60
name: "SSD Temp" name: "SSD Temp"
unique_id: "$hostname_ssd_temp" unique_id: "$hostname_ssd_temp"
unit: "°C" unit: "C"
device_class: "temperature" device_class: "temperature"
icon: "mdi:thermometer" icon: "mdi:thermometer"
state_class: "measurement" state_class: "measurement"
gpu_usage: # GPU integre AMD (desactive - donnees non fiables sur ce modele)
enabled: true amd_gpu_usage:
discovery_enabled: true enabled: false
discovery_enabled: false
interval_s: 10 interval_s: 10
name: "GPU Usage" 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" unique_id: "$hostname_amd_gpu_usage"
unit: "%" unit: "%"
device_class: "" device_class: ""
icon: "mdi:expansion-card" icon: "mdi:gpu"
state_class: "measurement" state_class: "measurement"
amd_gpu_temp_c: amd_gpu_temp_c:
enabled: true enabled: false
discovery_enabled: true discovery_enabled: false
interval_s: 30 interval_s: 10
name: "AMD GPU Temp" name: "GPU Temp"
unique_id: "$hostname_amd_gpu_temp" unique_id: "$hostname_amd_gpu_temp"
unit: "°C" unit: "C"
device_class: "temperature" device_class: "temperature"
icon: "mdi:thermometer" icon: "mdi:thermometer"
state_class: "measurement" 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: memory_used_gb:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 20 interval_s: 10
name: "Memory Used" name: "Memory Used"
unique_id: "$hostname_memory_used" unique_id: "$hostname_memory_used"
unit: "GB" unit: "GB"
@@ -191,7 +89,7 @@ features:
memory_total_gb: memory_total_gb:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 240 interval_s: 3600
name: "Memory Total" name: "Memory Total"
unique_id: "$hostname_memory_total" unique_id: "$hostname_memory_total"
unit: "GB" unit: "GB"
@@ -201,47 +99,27 @@ features:
disk_free_gb: disk_free_gb:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 240 interval_s: 120
name: "Disk Free" name: "Disk Free"
unique_id: "$hostname_disk_free" unique_id: "$hostname_disk_free"
unit: "GB" unit: "GB"
device_class: "" device_class: ""
icon: "mdi:harddisk" icon: "mdi:harddisk"
state_class: "measurement" 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: ip_address:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 1200 interval_s: 120
name: "IP Address" name: "IP Address"
unique_id: "$hostname_ip" unique_id: "$hostname_ip"
unit: "" unit: ""
device_class: "" device_class: ""
icon: "mdi:ip-network" icon: "mdi:ip"
state_class: "" state_class: ""
battery_level: battery_level:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 240 interval_s: 60
name: "Battery Level" name: "Battery Level"
unique_id: "$hostname_battery_level" unique_id: "$hostname_battery_level"
unit: "%" unit: "%"
@@ -251,7 +129,7 @@ features:
battery_state: battery_state:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 240 interval_s: 60
name: "Battery State" name: "Battery State"
unique_id: "$hostname_battery_state" unique_id: "$hostname_battery_state"
unit: "" unit: ""
@@ -261,7 +139,7 @@ features:
power_state: power_state:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 240 interval_s: 60
name: "Power State" name: "Power State"
unique_id: "$hostname_power_state" unique_id: "$hostname_power_state"
unit: "" unit: ""
@@ -271,7 +149,7 @@ features:
kernel: kernel:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 14400 interval_s: 7200
name: "Kernel" name: "Kernel"
unique_id: "$hostname_kernel" unique_id: "$hostname_kernel"
unit: "" unit: ""
@@ -281,32 +159,103 @@ features:
os_version: os_version:
enabled: true enabled: true
discovery_enabled: true discovery_enabled: true
interval_s: 14400 interval_s: 7200
name: "OS Version" name: "OS Version"
unique_id: "$hostname_os_version" unique_id: "$hostname_os_version"
unit: "" unit: ""
device_class: "" device_class: ""
icon: "mdi:monitor" icon: "mdi:desktop-classic"
state_class: "" 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: commands:
enabled: true enabled: true
cooldown_s: 5 cooldown_s: 5
dry_run: false # true = simule les commandes sans les executer dry_run: false
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"
- "livebox_tv_channel"
power_backend: power_backend:
linux: "linux_logind_polkit" # or linux_sudoers linux: "linux_logind_polkit"
windows: "windows_service" windows: "windows_service"
screen_backend: screen_backend:
linux: "x11_xset" #"gnome_busctl" # or "x11_xset" linux: "gnome_busctl" # si pas GNOME: x11_xset
windows: "winapi_session" # or external_tool windows: "winapi_session"
publish: publish:
heartbeat_s: 30 heartbeat_s: 30
availability: true 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: paths:
linux_config: "/etc/pilot/config.yaml" linux_config: "/etc/pilot/config.yaml"
windows_config: "C:\\ProgramData\\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 std::time::{Duration, Instant};
use tracing::info; use tracing::info;
// Actions d'alimentation supportees (shutdown/reboot/sleep). // Actions d'alimentation supportees (shutdown/reboot/sleep/hibernate).
pub trait PowerControl { pub trait PowerControl {
fn shutdown(&self) -> Result<()>; fn shutdown(&self) -> Result<()>;
fn reboot(&self) -> Result<()>; fn reboot(&self) -> Result<()>;
fn sleep(&self) -> Result<()>; fn sleep(&self) -> Result<()>;
fn hibernate(&self) -> Result<()>;
} }
// Actions d'ecran supportees (on/off). // Actions d'ecran supportees (on/off).
@@ -16,18 +17,29 @@ pub trait ScreenControl {
fn screen_off(&self) -> Result<()>; fn screen_off(&self) -> Result<()>;
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CommandAction { pub enum CommandAction {
Shutdown, Shutdown,
Reboot, Reboot,
Sleep, Sleep,
Hibernate,
Screen, 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 { pub enum CommandValue {
On, On,
Off, Off,
Number(u8),
/// Valeur texte libre (ex: nom de chaine pour select HA).
Text(String),
} }
// Decode une action depuis le topic cmd/<action>/set. // 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), "shutdown" => Ok(CommandAction::Shutdown),
"reboot" => Ok(CommandAction::Reboot), "reboot" => Ok(CommandAction::Reboot),
"sleep" => Ok(CommandAction::Sleep), "sleep" => Ok(CommandAction::Sleep),
"hibernate" => Ok(CommandAction::Hibernate),
"screen" => Ok(CommandAction::Screen), "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"), _ => 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> { pub fn parse_value(payload: &[u8]) -> Result<CommandValue> {
let raw = String::from_utf8_lossy(payload).trim().to_uppercase(); let raw = String::from_utf8_lossy(payload).trim().to_string();
match raw.as_str() { match raw.to_uppercase().as_str() {
"ON" => Ok(CommandValue::On), "ON" => Ok(CommandValue::On),
"OFF" => Ok(CommandValue::Off), "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). // 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() { if allowlist.is_empty() {
return true; return true;
} }
let name = action_name(action); 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. // 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). // 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"); info!(?action, ?value, "dry-run command");
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -107,17 +146,36 @@ mod tests {
assert_eq!(parse_action(topic).unwrap(), CommandAction::Shutdown); 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] #[test]
fn parse_value_ok() { fn parse_value_ok() {
assert!(matches!(parse_value(b"ON").unwrap(), CommandValue::On)); assert!(matches!(parse_value(b"ON").unwrap(), CommandValue::On));
assert!(matches!(parse_value(b"off").unwrap(), CommandValue::Off)); assert!(matches!(parse_value(b"off").unwrap(), CommandValue::Off));
assert!(matches!(parse_value(b"75").unwrap(), CommandValue::Number(75)));
} }
#[test] #[test]
fn allowlist_checks() { fn allowlist_checks() {
let list = vec!["shutdown".to_string(), "screen".to_string()]; let list = vec!["shutdown".to_string(), "screen".to_string()];
assert!(allowlist_allows(&list, CommandAction::Shutdown)); assert!(allowlist_allows(&list, &CommandAction::Shutdown));
assert!(!allowlist_allows(&list, CommandAction::Reboot)); assert!(!allowlist_allows(&list, &CommandAction::Reboot));
} }
#[test] #[test]

View File

@@ -15,6 +15,14 @@ pub struct Config {
pub screen_backend: ScreenBackend, pub screen_backend: ScreenBackend,
pub publish: Publish, pub publish: Publish,
pub paths: Option<Paths>, 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)] #[derive(Debug, Clone, Deserialize)]
@@ -547,6 +555,87 @@ pub struct Paths {
pub windows_config: String, 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). // Charge la config depuis les chemins par defaut (OS + fallback).
pub fn load() -> Result<Config> { pub fn load() -> Result<Config> {
let candidates = candidate_paths(); let candidates = candidate_paths();
@@ -767,8 +856,8 @@ publish:
expand_variables(&mut cfg).unwrap(); expand_variables(&mut cfg).unwrap();
let hostname = get_hostname().unwrap(); let hostname = get_hostname().unwrap();
let metric = cfg.features.telemetry.metrics.get("cpu_usage").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.unique_id.as_deref(), Some(format!("{}_cpu_usage", hostname).as_str()));
assert_eq!(metric.name.as_deref(), Some(&format!("{} CPU Usage", hostname))); assert_eq!(metric.name.as_deref(), Some(format!("{} CPU Usage", hostname).as_str()));
let hostname = get_hostname().unwrap(); let hostname = get_hostname().unwrap();
assert_eq!(cfg.device.name, hostname); assert_eq!(cfg.device.name, hostname);
assert_eq!(cfg.device.identifiers[0], hostname); assert_eq!(cfg.device.identifiers[0], hostname);

View File

@@ -18,6 +18,7 @@ struct DeviceInfo {
suggested_area: Option<String>, suggested_area: Option<String>,
} }
// Entite generique (sensor, switch).
#[derive(Serialize)] #[derive(Serialize)]
struct EntityConfig { struct EntityConfig {
name: String, name: String,
@@ -43,6 +44,42 @@ struct EntityConfig {
icon: Option<String>, 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. // Publie les entites HA discovery pour les capteurs et commandes standard.
pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> { pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
let base = base_device_topic(cfg); let base = base_device_topic(cfg);
@@ -91,32 +128,181 @@ pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
} }
} }
let switches = vec![ if cfg.features.commands.enabled {
("shutdown", "Shutdown", "cmd/shutdown/set"), // Switches power/screen standard
("reboot", "Reboot", "cmd/reboot/set"), let switches = vec![
("sleep", "Sleep", "cmd/sleep/set"), ("shutdown", "Shutdown", "cmd/shutdown/set", "mdi:power"),
("screen", "Screen", "cmd/screen/set"), ("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 { for (key, label, cmd, icon) in switches {
let entity_name = format!("{}_{}", key, cfg.device.name); // 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 { let entity = EntityConfig {
name: entity_name.clone(), name: format!("Keycode {}", cfg.device.name),
unique_id: format!("{}_{}", cfg.device.name, key), unique_id: format!("{}_keycode", cfg.device.name),
state_topic: format!("{}/{}/state", base, key), state_topic: format!("{}/keycode", base),
availability_topic: format!("{}/availability", base), availability_topic: format!("{}/availability", base),
payload_available: "online".to_string(), payload_available: "online".to_string(),
payload_not_available: "offline".to_string(), payload_not_available: "offline".to_string(),
device: DeviceInfo { ..device.clone() }, device: DeviceInfo { ..device.clone() },
command_topic: Some(format!("{}/{}", base, cmd)), command_topic: None,
payload_on: Some("ON".to_string()), payload_on: None,
payload_off: Some("OFF".to_string()), payload_off: None,
unit_of_measurement: None, unit_of_measurement: None,
device_class: Some("switch".to_string()), device_class: None,
state_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?; 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 config;
pub mod mqtt; pub mod mqtt;
pub mod ha; pub mod ha;
pub mod keycode;
pub mod m3u;
pub mod telemetry; pub mod telemetry;
pub mod commands; pub mod commands;
pub mod platform; 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 anyhow::{bail, Context, Result};
use tracing::debug; use tracing::debug;
use std::process::Command; use std::process::Command;
use std::path::Path;
use crate::commands::{CommandAction, CommandValue}; use crate::commands::{CommandAction, CommandValue};
@@ -12,13 +13,17 @@ pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
CommandAction::Shutdown => run("systemctl", &["poweroff"]), CommandAction::Shutdown => run("systemctl", &["poweroff"]),
CommandAction::Reboot => run("systemctl", &["reboot"]), CommandAction::Reboot => run("systemctl", &["reboot"]),
CommandAction::Sleep => run("systemctl", &["suspend"]), CommandAction::Sleep => run("systemctl", &["suspend"]),
CommandAction::Hibernate => run("systemctl", &["hibernate"]),
CommandAction::Screen => bail!("screen action not supported in power backend"), CommandAction::Screen => bail!("screen action not supported in power backend"),
_ => bail!("action not supported in power backend"),
}, },
"linux_sudoers" => match action { "linux_sudoers" => match action {
CommandAction::Shutdown => run("shutdown", &["-h", "now"]), CommandAction::Shutdown => run("shutdown", &["-h", "now"]),
CommandAction::Reboot => run("reboot", &[]), CommandAction::Reboot => run("reboot", &[]),
CommandAction::Sleep => run("systemctl", &["suspend"]), CommandAction::Sleep => run("systemctl", &["suspend"]),
CommandAction::Hibernate => run("systemctl", &["hibernate"]),
CommandAction::Screen => bail!("screen action not supported in power backend"), CommandAction::Screen => bail!("screen action not supported in power backend"),
_ => bail!("action not supported in power backend"),
}, },
_ => bail!("unknown linux power backend"), _ => bail!("unknown linux power backend"),
} }
@@ -41,19 +46,35 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
"1", "1",
], ],
), ),
CommandValue::On => run( CommandValue::On => {
"busctl", // Retirer le mode veille ecran
&[ run(
"--user", "busctl",
"set-property", &[
"org.gnome.Mutter.DisplayConfig", "--user",
"/org/gnome/Mutter/DisplayConfig", "set-property",
"org.gnome.Mutter.DisplayConfig", "org.gnome.Mutter.DisplayConfig",
"PowerSaveMode", "/org/gnome/Mutter/DisplayConfig",
"i", "org.gnome.Mutter.DisplayConfig",
"0", "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 { "x11_xset" => match value {
CommandValue::Off => { CommandValue::Off => {
@@ -64,11 +85,212 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
log_x11_env(); log_x11_env();
run("xset", &["dpms", "force", "on"]) run("xset", &["dpms", "force", "on"])
} }
_ => bail!("unsupported value for screen"),
}, },
_ => bail!("unknown linux screen backend"), _ => 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<()> { fn run(cmd: &str, args: &[&str]) -> Result<()> {
debug!(%cmd, args = ?args, "running command"); debug!(%cmd, args = ?args, "running command");
let output = Command::new(cmd) 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() { fn log_x11_env() {
let display_env = std::env::var("DISPLAY").unwrap_or_default(); let display_env = std::env::var("DISPLAY").unwrap_or_default();
let xauth_env = std::env::var("XAUTHORITY").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 linux;
pub mod windows; 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<()> { pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
windows::execute_power(backend, action) windows::execute_power(backend, action)
@@ -24,3 +24,93 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
linux::execute_screen(backend, value) 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?; 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"); tracing::info!("entering main event loop");
let mut telemetry = if self.config.features.telemetry.enabled { let mut telemetry = if self.config.features.telemetry.enabled {
@@ -128,7 +149,28 @@ impl Runtime {
self.config.publish.heartbeat_s, self.config.publish.heartbeat_s,
)); ));
let mut stats_tick = interval(Duration::from_secs(60)); 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(); 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(); let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown); tokio::pin!(shutdown);
@@ -149,19 +191,33 @@ impl Runtime {
} }
if !due.is_empty() { if !due.is_empty() {
// Metriques speciales gerees directement dans le runtime
let power_state_due = due.remove("power_state"); 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); let metrics = telemetry.as_mut().unwrap().read(&due);
for (name, value) in metrics { for (name, value) in metrics {
if let Err(err) = mqtt::publish_state(&client, &self.config, &name, &value).await { if let Err(err) = mqtt::publish_state(&client, &self.config, &name, &value).await {
warn!(error = %err, "publish state failed"); warn!(error = %err, "publish state failed");
} }
} }
if power_state_due && enabled_metrics.contains("power_state") { if power_state_due && enabled_metrics.contains("power_state") {
let current = detect_power_state(); let current = detect_power_state();
if let Err(err) = mqtt::publish_state(&client, &self.config, "power_state", &current).await { if let Err(err) = mqtt::publish_state(&client, &self.config, "power_state", &current).await {
warn!(error = %err, "publish power_state failed"); 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() => { _ = heartbeat_tick.tick() => {
@@ -169,6 +225,28 @@ impl Runtime {
if let Err(err) = mqtt::publish_status(&client, &self.config, &status).await { if let Err(err) = mqtt::publish_status(&client, &self.config, &status).await {
warn!(error = %err, "publish status failed"); 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() => { _ = stats_tick.tick() => {
let published = mqtt::take_publish_count(); let published = mqtt::take_publish_count();
@@ -181,12 +259,84 @@ impl Runtime {
&client, &client,
&self.config, &self.config,
&mut last_exec, &mut last_exec,
&channels_map,
&mut current_channels,
&mut app_states,
&topic, &topic,
&payload, &payload,
).await { ).await {
warn!(error = %err, "command handling failed"); 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 => { _ = &mut shutdown => {
if let Err(err) = mqtt::publish_availability(&client, &self.config, false).await { if let Err(err) = mqtt::publish_availability(&client, &self.config, false).await {
warn!(error = %err, "publish availability offline failed"); warn!(error = %err, "publish availability offline failed");
@@ -258,10 +408,42 @@ fn capabilities(cfg: &Config) -> Capabilities {
let mut commands = Vec::new(); let mut commands = Vec::new();
if cfg.features.commands.enabled { if cfg.features.commands.enabled {
commands.push("shutdown".to_string()); let base_cmds = ["shutdown", "reboot", "sleep", "hibernate", "screen", "volume", "system_update"];
commands.push("reboot".to_string()); for cmd in &base_cmds {
commands.push("sleep".to_string()); if cfg.features.commands.allowlist.is_empty()
commands.push("screen".to_string()); || 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 { Capabilities {
@@ -346,11 +528,15 @@ fn detect_power_state_logind() -> Option<String> {
} }
None None
} }
// Traite une commande entrante (topic + payload) avec cooldown et dry-run. // Traite une commande entrante (topic + payload) avec cooldown et dry-run.
async fn handle_command( async fn handle_command(
client: &rumqttc::AsyncClient, client: &rumqttc::AsyncClient,
cfg: &Config, cfg: &Config,
last_exec: &mut HashMap<CommandAction, std::time::Instant>, 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, topic: &str,
payload: &[u8], payload: &[u8],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@@ -358,72 +544,368 @@ async fn handle_command(
let value = commands::parse_value(payload)?; let value = commands::parse_value(payload)?;
debug!(%topic, ?action, ?value, "command received"); 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(()); 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(()); return Ok(());
} }
if cfg.features.commands.dry_run { if cfg.features.commands.dry_run {
commands::execute_dry_run(action, value)?; commands::execute_dry_run(&action, &value)?;
publish_command_state(client, cfg, action, value).await?; publish_command_state(client, cfg, &action, &value).await?;
return Ok(()); return Ok(());
} }
match action { match &action {
CommandAction::Shutdown => { CommandAction::Shutdown => {
if matches!(value, CommandValue::Off) { if matches!(value, CommandValue::On) {
platform::execute_power(&backend_power(cfg), action)?; // 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?; 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 => { CommandAction::Reboot => {
if matches!(value, CommandValue::Off) { if matches!(value, CommandValue::On) {
platform::execute_power(&backend_power(cfg), action)?; mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await?;
mqtt::publish_state(client, cfg, "power_state", "on").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 => { CommandAction::Sleep => {
if matches!(value, CommandValue::Off) { if matches!(value, CommandValue::On) {
platform::execute_power(&backend_power(cfg), action)?; mqtt::publish_switch_state(client, cfg, "sleep", "OFF").await?;
mqtt::publish_state(client, cfg, "power_state", "sleep").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 => { CommandAction::Screen => {
let backend = backend_screen(cfg); let backend = backend_screen(cfg);
debug!(backend = %backend, ?value, "executing screen command"); debug!(backend = %backend, ?value, "executing screen command");
platform::execute_screen(&backend, value)?; platform::execute_screen(&backend, value.clone())?;
publish_command_state(client, cfg, action, value).await?; 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(()) 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) { 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, "shutdown", "OFF").await;
let _ = mqtt::publish_switch_state(client, cfg, "reboot", "ON").await; let _ = mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await;
let _ = mqtt::publish_switch_state(client, cfg, "sleep", "ON").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, "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. // Publie l'etat d'une commande pour Home Assistant.
async fn publish_command_state( async fn publish_command_state(
client: &rumqttc::AsyncClient, client: &rumqttc::AsyncClient,
cfg: &Config, cfg: &Config,
action: CommandAction, action: &CommandAction,
value: CommandValue, value: &CommandValue,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let state = match value { let state = match value {
CommandValue::On => "ON", CommandValue::On => "ON".to_string(),
CommandValue::Off => "OFF", CommandValue::Off => "OFF".to_string(),
CommandValue::Number(n) => n.to_string(),
CommandValue::Text(s) => s.clone(),
}; };
let name = commands::action_name(action); 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 "══════════════════════════════════════════════════"