feat(yoga14): remote control, app management, install script
- Add keycode module: G7BTS Rii remote control support (evdev, auto-reconnect) - Add key bindings: single/double press detection with configurable window - KEY_HOMEPAGE: single=VacuumTube, double=LiveboxTV - KEY_OK: inject Enter keypress via ydotool - KEY_PAGEUP/DOWN: LiveboxTV channel navigation - Add M3U parser and channel selector for LiveboxTV (51 channels) - Add volume entity (wpctl/PipeWire, 2s polling) - Add app management: vacuum_tube, livebox_tv (start/stop/state via MQTT) - Add grace period to prevent app state bounce after stop - Fix screen ON via GNOME busctl: add SimulateUserActivity - Fix power commands: trigger on ON, publish OFF immediately (momentary buttons) - Disable GPU temp/usage entities - Add install script: build, deploy to ~/pilot, systemd user service - Fix service startup: WantedBy=graphical-session.target (full GNOME env at launch) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
amelioration.md
Normal file
16
amelioration.md
Normal 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
|
||||
@@ -287,11 +287,34 @@ features:
|
||||
device_class: ""
|
||||
icon: "mdi:desktop-classic"
|
||||
state_class: ""
|
||||
# Niveau sonore actuel (via wpctl, PipeWire)
|
||||
volume_level:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 30
|
||||
name: "Volume Level"
|
||||
unique_id: "$hostname_volume_level"
|
||||
unit: "%"
|
||||
device_class: ""
|
||||
icon: "mdi:volume-high"
|
||||
state_class: "measurement"
|
||||
commands:
|
||||
enabled: true
|
||||
cooldown_s: 5
|
||||
dry_run: true
|
||||
allowlist: ["shutdown", "reboot", "sleep", "screen"]
|
||||
allowlist:
|
||||
- "shutdown"
|
||||
- "reboot"
|
||||
- "sleep"
|
||||
- "hibernate"
|
||||
- "screen"
|
||||
- "volume"
|
||||
- "system_update"
|
||||
- "inhibit_sleep"
|
||||
- "app_vacuum_tube"
|
||||
- "app_livebox_tv"
|
||||
- "bluetooth_k3pro"
|
||||
- "bluetooth_g7bts"
|
||||
|
||||
power_backend:
|
||||
linux: "linux_logind_polkit" # or linux_sudoers
|
||||
@@ -305,7 +328,40 @@ publish:
|
||||
heartbeat_s: 30
|
||||
availability: true
|
||||
|
||||
# Applications pilotables depuis Home Assistant
|
||||
# start_args: arguments passes au demarrage (ex: --fullscreen)
|
||||
# process_check: motif pour pgrep/pkill (recherche dans le nom complet du processus)
|
||||
apps:
|
||||
- name: "vacuum_tube"
|
||||
display_name: "VacuumTube"
|
||||
enabled: true
|
||||
start_cmd: "flatpak"
|
||||
start_args: ["run", "rocks.shy.VacuumTube"]
|
||||
process_check: "rocks.shy.VacuumTube"
|
||||
- name: "livebox_tv"
|
||||
display_name: "Livebox TV"
|
||||
enabled: true
|
||||
start_cmd: "vlc"
|
||||
start_args:
|
||||
- "--fullscreen"
|
||||
- "--network-caching=1000"
|
||||
- "/home/gilles/pilot/iptv/france_tv.m3u" # ou chemin local apres installation
|
||||
process_check: "vlc"
|
||||
channels_m3u: "/home/gilles/pilot/iptv/france_tv.m3u" # active le selecteur de chaine HA
|
||||
|
||||
# Appareils Bluetooth a surveiller et controler
|
||||
# mac: adresse MAC (obtenue via: bluetoothctl paired-devices)
|
||||
# Prerequis: utilisateur dans le groupe 'bluetooth'
|
||||
bluetooth:
|
||||
enabled: true
|
||||
devices:
|
||||
- name: "k3pro"
|
||||
mac: "F1:B7:7F:BC:7B:00"
|
||||
display_name: "ThinkPlus K3 Pro"
|
||||
- name: "g7bts"
|
||||
mac: "AA:23:02:16:32:6F"
|
||||
display_name: "Rii G7BTS"
|
||||
|
||||
paths:
|
||||
linux_config: "/etc/pilot/config.yaml"
|
||||
windows_config: "C:\\ProgramData\\Pilot\\config.yaml"
|
||||
# Codex modified 2025-12-29_0224
|
||||
|
||||
147
config/france_tv.m3u
Normal file
147
config/france_tv.m3u
Normal 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
20
error.md
Normal 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
127
iptv/france_tv.m3u
Normal 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
|
||||
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
[Unit]
|
||||
Description=Pilot v2 MQTT Agent
|
||||
After=network-online.target
|
||||
Documentation=https://gitea.maison43.duckdns.org/gilles/pilot
|
||||
After=network-online.target graphical-session.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pilot
|
||||
WorkingDirectory=/opt/pilot
|
||||
ExecStart=/opt/pilot/pilot
|
||||
WorkingDirectory=%h/pilot/pilot-v2
|
||||
ExecStart=%h/pilot/pilot-v2/target/release/pilot-v2
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
Environment=RUST_LOG=info
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/%U
|
||||
Environment=WAYLAND_DISPLAY=wayland-0
|
||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
|
||||
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
# Codex created 2025-12-29_0224
|
||||
# Configuration Pilot v2 - yoga14 (Lenovo Yoga)
|
||||
# Hostname auto-detecte: yoga14
|
||||
|
||||
device:
|
||||
name: $hostname
|
||||
identifiers: ["$hostname"]
|
||||
manufacturer: "Asus"
|
||||
model: "Laptop"
|
||||
manufacturer: "Lenovo"
|
||||
model: "Yoga 14"
|
||||
sw_version: "2.0.0"
|
||||
suggested_area: "Bureau"
|
||||
|
||||
mqtt:
|
||||
host: "10.0.0.3"
|
||||
host: "10.0.0.3" # <- adresse de ton serveur Home Assistant / broker Mosquitto
|
||||
port: 1883
|
||||
username: ""
|
||||
username: "" # <- si authentification activee sur Mosquitto
|
||||
password: ""
|
||||
base_topic: "pilot"
|
||||
discovery_prefix: "homeassistant"
|
||||
client_id: "$hostname"
|
||||
keepalive_s: 60
|
||||
qos: 0
|
||||
qos: 1
|
||||
retain_states: true
|
||||
reconnect:
|
||||
attempts: 3
|
||||
retry_delay_s: 1
|
||||
short_wait_s: 60
|
||||
long_wait_s: 3600
|
||||
|
||||
features:
|
||||
telemetry:
|
||||
@@ -38,33 +35,13 @@ features:
|
||||
device_class: ""
|
||||
icon: "mdi:chip"
|
||||
state_class: "measurement"
|
||||
pilot_v2_cpu_usage:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "Pilot V2 CPU Usage"
|
||||
unique_id: "$hostname_pilot_v2_cpu_usage"
|
||||
unit: "%"
|
||||
device_class: ""
|
||||
icon: "mdi:apps"
|
||||
state_class: "measurement"
|
||||
pilot_v2_mem_used_mb:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "Pilot V2 Memory Used"
|
||||
unique_id: "$hostname_pilot_v2_mem_used_mb"
|
||||
unit: "MB"
|
||||
device_class: ""
|
||||
icon: "mdi:memory"
|
||||
state_class: "measurement"
|
||||
cpu_temp_c:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 30
|
||||
interval_s: 10
|
||||
name: "CPU Temp"
|
||||
unique_id: "$hostname_cpu_temp"
|
||||
unit: "°C"
|
||||
unit: "C"
|
||||
device_class: "temperature"
|
||||
icon: "mdi:thermometer"
|
||||
state_class: "measurement"
|
||||
@@ -74,114 +51,35 @@ features:
|
||||
interval_s: 60
|
||||
name: "SSD Temp"
|
||||
unique_id: "$hostname_ssd_temp"
|
||||
unit: "°C"
|
||||
unit: "C"
|
||||
device_class: "temperature"
|
||||
icon: "mdi:thermometer"
|
||||
state_class: "measurement"
|
||||
gpu_usage:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
# GPU integre AMD (desactive - donnees non fiables sur ce modele)
|
||||
amd_gpu_usage:
|
||||
enabled: false
|
||||
discovery_enabled: false
|
||||
interval_s: 10
|
||||
name: "GPU Usage"
|
||||
unique_id: "$hostname_gpu_usage"
|
||||
unit: "%"
|
||||
device_class: ""
|
||||
icon: "mdi:expansion-card"
|
||||
state_class: "measurement"
|
||||
gpu0_usage:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "GPU0 Usage"
|
||||
unique_id: "$hostname_gpu0_usage"
|
||||
unit: "%"
|
||||
device_class: ""
|
||||
icon: "mdi:expansion-card"
|
||||
state_class: "measurement"
|
||||
gpu1_usage:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "GPU1 Usage"
|
||||
unique_id: "$hostname_gpu1_usage"
|
||||
unit: "%"
|
||||
device_class: ""
|
||||
icon: "mdi:expansion-card"
|
||||
state_class: "measurement"
|
||||
gpu0_temp_c:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 30
|
||||
name: "GPU0 Temp"
|
||||
unique_id: "$hostname_gpu0_temp"
|
||||
unit: "°C"
|
||||
device_class: "temperature"
|
||||
icon: "mdi:thermometer"
|
||||
state_class: "measurement"
|
||||
gpu1_temp_c:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 30
|
||||
name: "GPU1 Temp"
|
||||
unique_id: "$hostname_gpu1_temp"
|
||||
unit: "°C"
|
||||
device_class: "temperature"
|
||||
icon: "mdi:thermometer"
|
||||
state_class: "measurement"
|
||||
gpu0_mem_used_gb:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "GPU0 Memory Used"
|
||||
unique_id: "$hostname_gpu0_mem_used"
|
||||
unit: "GB"
|
||||
device_class: ""
|
||||
icon: "mdi:memory"
|
||||
state_class: "measurement"
|
||||
gpu1_mem_used_gb:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "GPU1 Memory Used"
|
||||
unique_id: "$hostname_gpu1_mem_used"
|
||||
unit: "GB"
|
||||
device_class: ""
|
||||
icon: "mdi:memory"
|
||||
state_class: "measurement"
|
||||
amd_gpu_usage:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "AMD GPU Usage"
|
||||
unique_id: "$hostname_amd_gpu_usage"
|
||||
unit: "%"
|
||||
device_class: ""
|
||||
icon: "mdi:expansion-card"
|
||||
icon: "mdi:gpu"
|
||||
state_class: "measurement"
|
||||
amd_gpu_temp_c:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 30
|
||||
name: "AMD GPU Temp"
|
||||
enabled: false
|
||||
discovery_enabled: false
|
||||
interval_s: 10
|
||||
name: "GPU Temp"
|
||||
unique_id: "$hostname_amd_gpu_temp"
|
||||
unit: "°C"
|
||||
unit: "C"
|
||||
device_class: "temperature"
|
||||
icon: "mdi:thermometer"
|
||||
state_class: "measurement"
|
||||
amd_gpu_mem_used_gb:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 10
|
||||
name: "AMD GPU Memory Used"
|
||||
unique_id: "$hostname_amd_gpu_mem_used"
|
||||
unit: "GB"
|
||||
device_class: ""
|
||||
icon: "mdi:memory"
|
||||
state_class: "measurement"
|
||||
memory_used_gb:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 20
|
||||
interval_s: 10
|
||||
name: "Memory Used"
|
||||
unique_id: "$hostname_memory_used"
|
||||
unit: "GB"
|
||||
@@ -191,7 +89,7 @@ features:
|
||||
memory_total_gb:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 240
|
||||
interval_s: 3600
|
||||
name: "Memory Total"
|
||||
unique_id: "$hostname_memory_total"
|
||||
unit: "GB"
|
||||
@@ -201,47 +99,27 @@ features:
|
||||
disk_free_gb:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 240
|
||||
interval_s: 120
|
||||
name: "Disk Free"
|
||||
unique_id: "$hostname_disk_free"
|
||||
unit: "GB"
|
||||
device_class: ""
|
||||
icon: "mdi:harddisk"
|
||||
state_class: "measurement"
|
||||
fan_cpu_rpm:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 23
|
||||
name: "CPU Fan"
|
||||
unique_id: "$hostname_fan_cpu"
|
||||
unit: "RPM"
|
||||
device_class: ""
|
||||
icon: "mdi:fan"
|
||||
state_class: "measurement"
|
||||
fan_gpu_rpm:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 23
|
||||
name: "GPU Fan"
|
||||
unique_id: "$hostname_fan_gpu"
|
||||
unit: "RPM"
|
||||
device_class: ""
|
||||
icon: "mdi:fan"
|
||||
state_class: "measurement"
|
||||
ip_address:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 1200
|
||||
interval_s: 120
|
||||
name: "IP Address"
|
||||
unique_id: "$hostname_ip"
|
||||
unit: ""
|
||||
device_class: ""
|
||||
icon: "mdi:ip-network"
|
||||
icon: "mdi:ip"
|
||||
state_class: ""
|
||||
battery_level:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 240
|
||||
interval_s: 60
|
||||
name: "Battery Level"
|
||||
unique_id: "$hostname_battery_level"
|
||||
unit: "%"
|
||||
@@ -251,7 +129,7 @@ features:
|
||||
battery_state:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 240
|
||||
interval_s: 60
|
||||
name: "Battery State"
|
||||
unique_id: "$hostname_battery_state"
|
||||
unit: ""
|
||||
@@ -261,7 +139,7 @@ features:
|
||||
power_state:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 240
|
||||
interval_s: 60
|
||||
name: "Power State"
|
||||
unique_id: "$hostname_power_state"
|
||||
unit: ""
|
||||
@@ -271,7 +149,7 @@ features:
|
||||
kernel:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 14400
|
||||
interval_s: 7200
|
||||
name: "Kernel"
|
||||
unique_id: "$hostname_kernel"
|
||||
unit: ""
|
||||
@@ -281,32 +159,103 @@ features:
|
||||
os_version:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 14400
|
||||
interval_s: 7200
|
||||
name: "OS Version"
|
||||
unique_id: "$hostname_os_version"
|
||||
unit: ""
|
||||
device_class: ""
|
||||
icon: "mdi:monitor"
|
||||
icon: "mdi:desktop-classic"
|
||||
state_class: ""
|
||||
volume_level:
|
||||
enabled: true
|
||||
discovery_enabled: true
|
||||
interval_s: 30
|
||||
name: "Volume Level"
|
||||
unique_id: "$hostname_volume_level"
|
||||
unit: "%"
|
||||
device_class: ""
|
||||
icon: "mdi:volume-high"
|
||||
state_class: "measurement"
|
||||
commands:
|
||||
enabled: true
|
||||
cooldown_s: 5
|
||||
dry_run: false # true = simule les commandes sans les executer
|
||||
allowlist: ["shutdown", "reboot", "sleep", "screen"]
|
||||
dry_run: false
|
||||
allowlist:
|
||||
- "shutdown"
|
||||
- "reboot"
|
||||
- "sleep"
|
||||
- "hibernate"
|
||||
- "screen"
|
||||
- "volume"
|
||||
- "system_update"
|
||||
- "inhibit_sleep"
|
||||
- "app_vacuum_tube"
|
||||
- "app_livebox_tv"
|
||||
- "bluetooth_k3pro"
|
||||
- "bluetooth_g7bts"
|
||||
- "livebox_tv_channel"
|
||||
|
||||
power_backend:
|
||||
linux: "linux_logind_polkit" # or linux_sudoers
|
||||
linux: "linux_logind_polkit"
|
||||
windows: "windows_service"
|
||||
|
||||
screen_backend:
|
||||
linux: "x11_xset" #"gnome_busctl" # or "x11_xset"
|
||||
windows: "winapi_session" # or external_tool
|
||||
linux: "gnome_busctl" # si pas GNOME: x11_xset
|
||||
windows: "winapi_session"
|
||||
|
||||
publish:
|
||||
heartbeat_s: 30
|
||||
availability: true
|
||||
|
||||
apps:
|
||||
- name: "vacuum_tube"
|
||||
display_name: "VacuumTube"
|
||||
enabled: true
|
||||
start_cmd: "flatpak"
|
||||
start_args: ["run", "--device=dri", "rocks.shy.VacuumTube"]
|
||||
process_check: "vacuumtube"
|
||||
- name: "livebox_tv"
|
||||
display_name: "Livebox TV"
|
||||
enabled: true
|
||||
start_cmd: "vlc"
|
||||
start_args:
|
||||
- "--fullscreen"
|
||||
- "--network-caching=1000"
|
||||
- "../iptv/france_tv.m3u"
|
||||
process_check: "vlc"
|
||||
channels_m3u: "../iptv/france_tv.m3u"
|
||||
channel_next_key: "KEY_PAGEUP"
|
||||
channel_prev_key: "KEY_PAGEDOWN"
|
||||
|
||||
bluetooth:
|
||||
enabled: true
|
||||
devices:
|
||||
- name: "k3pro"
|
||||
mac: "F1:B7:7F:BC:7B:00"
|
||||
display_name: "ThinkPlus K3 Pro"
|
||||
- name: "g7bts"
|
||||
mac: "AA:23:02:16:32:6F"
|
||||
display_name: "Rii G7BTS"
|
||||
|
||||
paths:
|
||||
linux_config: "/etc/pilot/config.yaml"
|
||||
windows_config: "C:\\ProgramData\\Pilot\\config.yaml"
|
||||
# Codex modified 2025-12-29_0224
|
||||
|
||||
# Lecture des touches clavier/telecommande via evdev
|
||||
# Necessite: utilisateur dans le groupe 'input' (sudo usermod -aG input $USER)
|
||||
# Pour trouver le device: ls -la /dev/input/by-id/ apres connexion de la telecommande
|
||||
keycodes:
|
||||
enabled: true
|
||||
devices:
|
||||
- "G7BTS Keyboard" # nom tel qu'il apparait dans /sys/class/input/*/device/name
|
||||
|
||||
# Liaisons touches → actions (simple / double appui)
|
||||
key_bindings:
|
||||
enabled: true
|
||||
bindings:
|
||||
- key: "KEY_HOMEPAGE"
|
||||
single_press: "vacuum_tube" # appui simple → toggle VacuumTube
|
||||
double_press: "livebox_tv" # double appui → toggle LiveboxTV
|
||||
double_press_ms: 400 # fenetre de detection en ms
|
||||
- key: "KEY_OK"
|
||||
single_press: "key:28" # touche OK → injecte Enter (code 28) via ydotool
|
||||
|
||||
@@ -3,11 +3,12 @@ use anyhow::{bail, Result};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::info;
|
||||
|
||||
// Actions d'alimentation supportees (shutdown/reboot/sleep).
|
||||
// Actions d'alimentation supportees (shutdown/reboot/sleep/hibernate).
|
||||
pub trait PowerControl {
|
||||
fn shutdown(&self) -> Result<()>;
|
||||
fn reboot(&self) -> Result<()>;
|
||||
fn sleep(&self) -> Result<()>;
|
||||
fn hibernate(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
// Actions d'ecran supportees (on/off).
|
||||
@@ -16,18 +17,29 @@ pub trait ScreenControl {
|
||||
fn screen_off(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum CommandAction {
|
||||
Shutdown,
|
||||
Reboot,
|
||||
Sleep,
|
||||
Hibernate,
|
||||
Screen,
|
||||
Volume,
|
||||
App(String),
|
||||
Bluetooth(String),
|
||||
SystemUpdate,
|
||||
InhibitSleep,
|
||||
/// Selecteur de chaine TV pour une app (ex: TvChannel("livebox_tv")).
|
||||
TvChannel(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommandValue {
|
||||
On,
|
||||
Off,
|
||||
Number(u8),
|
||||
/// Valeur texte libre (ex: nom de chaine pour select HA).
|
||||
Text(String),
|
||||
}
|
||||
|
||||
// Decode une action depuis le topic cmd/<action>/set.
|
||||
@@ -41,28 +53,65 @@ pub fn parse_action(topic: &str) -> Result<CommandAction> {
|
||||
"shutdown" => Ok(CommandAction::Shutdown),
|
||||
"reboot" => Ok(CommandAction::Reboot),
|
||||
"sleep" => Ok(CommandAction::Sleep),
|
||||
"hibernate" => Ok(CommandAction::Hibernate),
|
||||
"screen" => Ok(CommandAction::Screen),
|
||||
"volume" => Ok(CommandAction::Volume),
|
||||
"system_update" => Ok(CommandAction::SystemUpdate),
|
||||
"inhibit_sleep" => Ok(CommandAction::InhibitSleep),
|
||||
other if other.ends_with("_channel") => {
|
||||
let app = other.trim_end_matches("_channel");
|
||||
Ok(CommandAction::TvChannel(app.to_string()))
|
||||
}
|
||||
other if other.starts_with("app_") => {
|
||||
Ok(CommandAction::App(other.trim_start_matches("app_").to_string()))
|
||||
}
|
||||
other if other.starts_with("bluetooth_") => {
|
||||
Ok(CommandAction::Bluetooth(other.trim_start_matches("bluetooth_").to_string()))
|
||||
}
|
||||
_ => bail!("unknown action"),
|
||||
}
|
||||
}
|
||||
|
||||
// Decode une valeur ON/OFF (insensible a la casse).
|
||||
// Decode une valeur ON/OFF, numerique (0-100) ou texte libre (ex: nom de chaine).
|
||||
pub fn parse_value(payload: &[u8]) -> Result<CommandValue> {
|
||||
let raw = String::from_utf8_lossy(payload).trim().to_uppercase();
|
||||
match raw.as_str() {
|
||||
let raw = String::from_utf8_lossy(payload).trim().to_string();
|
||||
match raw.to_uppercase().as_str() {
|
||||
"ON" => Ok(CommandValue::On),
|
||||
"OFF" => Ok(CommandValue::Off),
|
||||
_ => bail!("invalid payload"),
|
||||
_ => {
|
||||
if let Ok(n) = raw.parse::<u8>() {
|
||||
Ok(CommandValue::Number(n))
|
||||
} else {
|
||||
Ok(CommandValue::Text(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertit une action en nom utilise par la config et les topics MQTT.
|
||||
pub fn action_name(action: &CommandAction) -> String {
|
||||
match action {
|
||||
CommandAction::Shutdown => "shutdown".to_string(),
|
||||
CommandAction::Reboot => "reboot".to_string(),
|
||||
CommandAction::Sleep => "sleep".to_string(),
|
||||
CommandAction::Hibernate => "hibernate".to_string(),
|
||||
CommandAction::Screen => "screen".to_string(),
|
||||
CommandAction::Volume => "volume".to_string(),
|
||||
CommandAction::SystemUpdate => "system_update".to_string(),
|
||||
CommandAction::InhibitSleep => "inhibit_sleep".to_string(),
|
||||
CommandAction::App(name) => format!("app_{}", name),
|
||||
CommandAction::Bluetooth(name) => format!("bluetooth_{}", name),
|
||||
CommandAction::TvChannel(name) => format!("{}_channel", name),
|
||||
}
|
||||
}
|
||||
|
||||
// Verifie si l'action est autorisee par l'allowlist (vide = tout autoriser).
|
||||
pub fn allowlist_allows(allowlist: &[String], action: CommandAction) -> bool {
|
||||
pub fn allowlist_allows(allowlist: &[String], action: &CommandAction) -> bool {
|
||||
if allowlist.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let name = action_name(action);
|
||||
allowlist.iter().any(|item| item == name)
|
||||
allowlist.iter().any(|item| item == &name)
|
||||
}
|
||||
|
||||
// Verifie le cooldown et renvoie true si l'action est autorisee.
|
||||
@@ -82,21 +131,11 @@ pub fn allow_command(
|
||||
}
|
||||
|
||||
// Execute une commande en mode dry-run (journalise seulement).
|
||||
pub fn execute_dry_run(action: CommandAction, value: CommandValue) -> Result<()> {
|
||||
pub fn execute_dry_run(action: &CommandAction, value: &CommandValue) -> Result<()> {
|
||||
info!(?action, ?value, "dry-run command");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Convertit une action en nom utilise par la config.
|
||||
pub fn action_name(action: CommandAction) -> &'static str {
|
||||
match action {
|
||||
CommandAction::Shutdown => "shutdown",
|
||||
CommandAction::Reboot => "reboot",
|
||||
CommandAction::Sleep => "sleep",
|
||||
CommandAction::Screen => "screen",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -107,17 +146,36 @@ mod tests {
|
||||
assert_eq!(parse_action(topic).unwrap(), CommandAction::Shutdown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_action_hibernate() {
|
||||
let topic = "pilot/device/cmd/hibernate/set";
|
||||
assert_eq!(parse_action(topic).unwrap(), CommandAction::Hibernate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_action_app() {
|
||||
let topic = "pilot/device/cmd/app_vacuum_tube/set";
|
||||
assert_eq!(parse_action(topic).unwrap(), CommandAction::App("vacuum_tube".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_action_bluetooth() {
|
||||
let topic = "pilot/device/cmd/bluetooth_k3pro/set";
|
||||
assert_eq!(parse_action(topic).unwrap(), CommandAction::Bluetooth("k3pro".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_value_ok() {
|
||||
assert!(matches!(parse_value(b"ON").unwrap(), CommandValue::On));
|
||||
assert!(matches!(parse_value(b"off").unwrap(), CommandValue::Off));
|
||||
assert!(matches!(parse_value(b"75").unwrap(), CommandValue::Number(75)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_checks() {
|
||||
let list = vec!["shutdown".to_string(), "screen".to_string()];
|
||||
assert!(allowlist_allows(&list, CommandAction::Shutdown));
|
||||
assert!(!allowlist_allows(&list, CommandAction::Reboot));
|
||||
assert!(allowlist_allows(&list, &CommandAction::Shutdown));
|
||||
assert!(!allowlist_allows(&list, &CommandAction::Reboot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,6 +15,14 @@ pub struct Config {
|
||||
pub screen_backend: ScreenBackend,
|
||||
pub publish: Publish,
|
||||
pub paths: Option<Paths>,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<AppConfig>,
|
||||
#[serde(default)]
|
||||
pub bluetooth: BluetoothSettings,
|
||||
#[serde(default)]
|
||||
pub keycodes: KeycodeConfig,
|
||||
#[serde(default)]
|
||||
pub key_bindings: KeyBindingsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -547,6 +555,87 @@ pub struct Paths {
|
||||
pub windows_config: String,
|
||||
}
|
||||
|
||||
// Configuration d'une application pilotable (start/stop depuis HA).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
pub start_cmd: String,
|
||||
#[serde(default)]
|
||||
pub start_args: Vec<String>,
|
||||
pub process_check: String,
|
||||
/// Chemin vers un fichier M3U pour activer le selecteur de chaine (optionnel).
|
||||
#[serde(default)]
|
||||
pub channels_m3u: Option<String>,
|
||||
/// Touche (nom keycode) pour passer a la chaine suivante.
|
||||
#[serde(default)]
|
||||
pub channel_next_key: Option<String>,
|
||||
/// Touche (nom keycode) pour passer a la chaine precedente.
|
||||
#[serde(default)]
|
||||
pub channel_prev_key: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_double_press_ms() -> u64 {
|
||||
400
|
||||
}
|
||||
|
||||
// Liaison touche → action (simple / double appui).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct KeyBinding {
|
||||
/// Nom du keycode tel que publie par le module keycode (ex: "KEY_HOME").
|
||||
pub key: String,
|
||||
/// Action sur appui simple : nom d'une app (ex: "vacuum_tube") ou commande.
|
||||
#[serde(default)]
|
||||
pub single_press: Option<String>,
|
||||
/// Action sur double appui.
|
||||
#[serde(default)]
|
||||
pub double_press: Option<String>,
|
||||
/// Fenetre de detection du double appui en millisecondes.
|
||||
#[serde(default = "default_double_press_ms")]
|
||||
pub double_press_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct KeyBindingsConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub bindings: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
// Configuration du lecteur de touches (telecommande/clavier evdev).
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct KeycodeConfig {
|
||||
/// Active l'ecoute des evenements clavier.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Chemins des devices a surveiller (ex: /dev/input/by-id/...).
|
||||
#[serde(default)]
|
||||
pub devices: Vec<String>,
|
||||
}
|
||||
|
||||
// Configuration Bluetooth (appareils a surveiller/controler).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BluetoothDevice {
|
||||
pub name: String,
|
||||
pub mac: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct BluetoothSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub devices: Vec<BluetoothDevice>,
|
||||
}
|
||||
|
||||
// Charge la config depuis les chemins par defaut (OS + fallback).
|
||||
pub fn load() -> Result<Config> {
|
||||
let candidates = candidate_paths();
|
||||
@@ -767,8 +856,8 @@ publish:
|
||||
expand_variables(&mut cfg).unwrap();
|
||||
let hostname = get_hostname().unwrap();
|
||||
let metric = cfg.features.telemetry.metrics.get("cpu_usage").unwrap();
|
||||
assert_eq!(metric.unique_id.as_deref(), Some(&format!("{}_cpu_usage", hostname)));
|
||||
assert_eq!(metric.name.as_deref(), Some(&format!("{} CPU Usage", hostname)));
|
||||
assert_eq!(metric.unique_id.as_deref(), Some(format!("{}_cpu_usage", hostname).as_str()));
|
||||
assert_eq!(metric.name.as_deref(), Some(format!("{} CPU Usage", hostname).as_str()));
|
||||
let hostname = get_hostname().unwrap();
|
||||
assert_eq!(cfg.device.name, hostname);
|
||||
assert_eq!(cfg.device.identifiers[0], hostname);
|
||||
|
||||
@@ -18,6 +18,7 @@ struct DeviceInfo {
|
||||
suggested_area: Option<String>,
|
||||
}
|
||||
|
||||
// Entite generique (sensor, switch).
|
||||
#[derive(Serialize)]
|
||||
struct EntityConfig {
|
||||
name: String,
|
||||
@@ -43,6 +44,42 @@ struct EntityConfig {
|
||||
icon: Option<String>,
|
||||
}
|
||||
|
||||
// Entite select HA (selecteur de chaine TV).
|
||||
#[derive(Serialize)]
|
||||
struct SelectEntityConfig {
|
||||
name: String,
|
||||
unique_id: String,
|
||||
state_topic: String,
|
||||
command_topic: String,
|
||||
availability_topic: String,
|
||||
payload_available: String,
|
||||
payload_not_available: String,
|
||||
options: Vec<String>,
|
||||
device: DeviceInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon: Option<String>,
|
||||
}
|
||||
|
||||
// Entite number HA (slider volume 0-100).
|
||||
#[derive(Serialize)]
|
||||
struct NumberEntityConfig {
|
||||
name: String,
|
||||
unique_id: String,
|
||||
state_topic: String,
|
||||
command_topic: String,
|
||||
availability_topic: String,
|
||||
payload_available: String,
|
||||
payload_not_available: String,
|
||||
device: DeviceInfo,
|
||||
min: f32,
|
||||
max: f32,
|
||||
step: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
unit_of_measurement: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon: Option<String>,
|
||||
}
|
||||
|
||||
// Publie les entites HA discovery pour les capteurs et commandes standard.
|
||||
pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
|
||||
let base = base_device_topic(cfg);
|
||||
@@ -91,32 +128,181 @@ pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let switches = vec![
|
||||
("shutdown", "Shutdown", "cmd/shutdown/set"),
|
||||
("reboot", "Reboot", "cmd/reboot/set"),
|
||||
("sleep", "Sleep", "cmd/sleep/set"),
|
||||
("screen", "Screen", "cmd/screen/set"),
|
||||
];
|
||||
if cfg.features.commands.enabled {
|
||||
// Switches power/screen standard
|
||||
let switches = vec![
|
||||
("shutdown", "Shutdown", "cmd/shutdown/set", "mdi:power"),
|
||||
("reboot", "Reboot", "cmd/reboot/set", "mdi:restart"),
|
||||
("sleep", "Sleep", "cmd/sleep/set", "mdi:sleep"),
|
||||
("hibernate", "Hibernate", "cmd/hibernate/set", "mdi:snowflake"),
|
||||
("screen", "Screen", "cmd/screen/set", "mdi:monitor"),
|
||||
("system_update", "System Update", "cmd/system_update/set", "mdi:update"),
|
||||
("inhibit_sleep", "Inhibit Sleep", "cmd/inhibit_sleep/set", "mdi:sleep-off"),
|
||||
];
|
||||
|
||||
for (key, _name, cmd) in switches {
|
||||
let entity_name = format!("{}_{}", key, cfg.device.name);
|
||||
for (key, label, cmd, icon) in switches {
|
||||
// Ignore hibernate/system_update si pas dans l'allowlist
|
||||
if !cfg.features.commands.allowlist.is_empty()
|
||||
&& !cfg.features.commands.allowlist.iter().any(|a| a == key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let entity_name = format!("{}_{}", key, cfg.device.name);
|
||||
let entity = EntityConfig {
|
||||
name: format!("{} {}", label, cfg.device.name),
|
||||
unique_id: format!("{}_{}", cfg.device.name, key),
|
||||
state_topic: format!("{}/{}/state", base, key),
|
||||
availability_topic: format!("{}/availability", base),
|
||||
payload_available: "online".to_string(),
|
||||
payload_not_available: "offline".to_string(),
|
||||
device: DeviceInfo { ..device.clone() },
|
||||
command_topic: Some(format!("{}/{}", base, cmd)),
|
||||
payload_on: Some("ON".to_string()),
|
||||
payload_off: Some("OFF".to_string()),
|
||||
unit_of_measurement: None,
|
||||
device_class: Some("switch".to_string()),
|
||||
state_class: None,
|
||||
icon: Some(icon.to_string()),
|
||||
};
|
||||
let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
|
||||
publish_discovery(client, &topic, &entity).await?;
|
||||
}
|
||||
|
||||
// Entite number pour le volume
|
||||
if cfg.features.commands.allowlist.is_empty()
|
||||
|| cfg.features.commands.allowlist.iter().any(|a| a == "volume")
|
||||
{
|
||||
let volume_entity = NumberEntityConfig {
|
||||
name: format!("Volume {}", cfg.device.name),
|
||||
unique_id: format!("{}_volume", cfg.device.name),
|
||||
state_topic: format!("{}/volume/state", base),
|
||||
command_topic: format!("{}/cmd/volume/set", base),
|
||||
availability_topic: format!("{}/availability", base),
|
||||
payload_available: "online".to_string(),
|
||||
payload_not_available: "offline".to_string(),
|
||||
device: DeviceInfo { ..device.clone() },
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
step: 1.0,
|
||||
unit_of_measurement: Some("%".to_string()),
|
||||
icon: Some("mdi:volume-high".to_string()),
|
||||
};
|
||||
let topic = format!(
|
||||
"{}/number/{}/volume_{}/config",
|
||||
prefix, cfg.device.name, cfg.device.name
|
||||
);
|
||||
publish_discovery(client, &topic, &volume_entity).await?;
|
||||
}
|
||||
|
||||
// Switches pour les apps configurees
|
||||
for app in &cfg.apps {
|
||||
if !app.enabled {
|
||||
continue;
|
||||
}
|
||||
let key = format!("app_{}", app.name);
|
||||
if !cfg.features.commands.allowlist.is_empty()
|
||||
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let entity_name = format!("{}_{}", key, cfg.device.name);
|
||||
let entity = EntityConfig {
|
||||
name: format!("{} {}", app.display_name, cfg.device.name),
|
||||
unique_id: format!("{}_{}", cfg.device.name, key),
|
||||
state_topic: format!("{}/{}/state", base, key),
|
||||
availability_topic: format!("{}/availability", base),
|
||||
payload_available: "online".to_string(),
|
||||
payload_not_available: "offline".to_string(),
|
||||
device: DeviceInfo { ..device.clone() },
|
||||
command_topic: Some(format!("{}/cmd/{}/set", base, key)),
|
||||
payload_on: Some("ON".to_string()),
|
||||
payload_off: Some("OFF".to_string()),
|
||||
unit_of_measurement: None,
|
||||
device_class: Some("switch".to_string()),
|
||||
state_class: None,
|
||||
icon: Some("mdi:application".to_string()),
|
||||
};
|
||||
let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
|
||||
publish_discovery(client, &topic, &entity).await?;
|
||||
|
||||
// Selecteur de chaine si channels_m3u est configure
|
||||
if let Some(m3u_path) = &app.channels_m3u {
|
||||
let channels = crate::m3u::parse_file(m3u_path);
|
||||
if !channels.is_empty() {
|
||||
let options: Vec<String> = channels.into_iter().map(|(name, _)| name).collect();
|
||||
let channel_key = format!("{}_channel", app.name);
|
||||
let channel_entity_name = format!("{}_{}", channel_key, cfg.device.name);
|
||||
let select = SelectEntityConfig {
|
||||
name: format!("{} Channel {}", app.display_name, cfg.device.name),
|
||||
unique_id: format!("{}_{}", cfg.device.name, channel_key),
|
||||
state_topic: format!("{}/{}/state", base, channel_key),
|
||||
command_topic: format!("{}/cmd/{}/set", base, channel_key),
|
||||
availability_topic: format!("{}/availability", base),
|
||||
payload_available: "online".to_string(),
|
||||
payload_not_available: "offline".to_string(),
|
||||
options,
|
||||
device: DeviceInfo { ..device.clone() },
|
||||
icon: Some("mdi:television-play".to_string()),
|
||||
};
|
||||
let topic = format!("{}/select/{}/{}/config", prefix, cfg.device.name, channel_entity_name);
|
||||
publish_discovery(client, &topic, &select).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Switches pour les appareils Bluetooth
|
||||
if cfg.bluetooth.enabled {
|
||||
for dev in &cfg.bluetooth.devices {
|
||||
let key = format!("bluetooth_{}", dev.name);
|
||||
if !cfg.features.commands.allowlist.is_empty()
|
||||
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let entity_name = format!("{}_{}", key, cfg.device.name);
|
||||
let entity = EntityConfig {
|
||||
name: format!("{} {}", dev.display_name, cfg.device.name),
|
||||
unique_id: format!("{}_{}", cfg.device.name, key),
|
||||
state_topic: format!("{}/{}/state", base, key),
|
||||
availability_topic: format!("{}/availability", base),
|
||||
payload_available: "online".to_string(),
|
||||
payload_not_available: "offline".to_string(),
|
||||
device: DeviceInfo { ..device.clone() },
|
||||
command_topic: Some(format!("{}/cmd/{}/set", base, key)),
|
||||
payload_on: Some("ON".to_string()),
|
||||
payload_off: Some("OFF".to_string()),
|
||||
unit_of_measurement: None,
|
||||
device_class: Some("switch".to_string()),
|
||||
state_class: None,
|
||||
icon: Some("mdi:bluetooth".to_string()),
|
||||
};
|
||||
let topic =
|
||||
format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
|
||||
publish_discovery(client, &topic, &entity).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capteur keycode (derniere touche pressee sur la telecommande)
|
||||
if cfg.keycodes.enabled {
|
||||
let entity = EntityConfig {
|
||||
name: entity_name.clone(),
|
||||
unique_id: format!("{}_{}", cfg.device.name, key),
|
||||
state_topic: format!("{}/{}/state", base, key),
|
||||
name: format!("Keycode {}", cfg.device.name),
|
||||
unique_id: format!("{}_keycode", cfg.device.name),
|
||||
state_topic: format!("{}/keycode", base),
|
||||
availability_topic: format!("{}/availability", base),
|
||||
payload_available: "online".to_string(),
|
||||
payload_not_available: "offline".to_string(),
|
||||
device: DeviceInfo { ..device.clone() },
|
||||
command_topic: Some(format!("{}/{}", base, cmd)),
|
||||
payload_on: Some("ON".to_string()),
|
||||
payload_off: Some("OFF".to_string()),
|
||||
command_topic: None,
|
||||
payload_on: None,
|
||||
payload_off: None,
|
||||
unit_of_measurement: None,
|
||||
device_class: Some("switch".to_string()),
|
||||
device_class: None,
|
||||
state_class: None,
|
||||
icon: Some("mdi:power".to_string()),
|
||||
icon: Some("mdi:remote".to_string()),
|
||||
};
|
||||
let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
|
||||
let entity_name = format!("keycode_{}", cfg.device.name);
|
||||
let topic = format!("{}/sensor/{}/{}/config", prefix, cfg.device.name, entity_name);
|
||||
publish_discovery(client, &topic, &entity).await?;
|
||||
}
|
||||
|
||||
|
||||
197
pilot-v2/src/keycode.rs
Normal file
197
pilot-v2/src/keycode.rs
Normal 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()
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
pub mod config;
|
||||
pub mod mqtt;
|
||||
pub mod ha;
|
||||
pub mod keycode;
|
||||
pub mod m3u;
|
||||
pub mod telemetry;
|
||||
pub mod commands;
|
||||
pub mod platform;
|
||||
|
||||
60
pilot-v2/src/m3u.rs
Normal file
60
pilot-v2/src/m3u.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Implementations Linux (logind, sudoers, gnome busctl, x11 xset).
|
||||
// Implementations Linux (logind, sudoers, gnome busctl, x11 xset, audio, apps, bluetooth).
|
||||
use anyhow::{bail, Context, Result};
|
||||
use tracing::debug;
|
||||
use std::process::Command;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::commands::{CommandAction, CommandValue};
|
||||
|
||||
@@ -12,13 +13,17 @@ pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
|
||||
CommandAction::Shutdown => run("systemctl", &["poweroff"]),
|
||||
CommandAction::Reboot => run("systemctl", &["reboot"]),
|
||||
CommandAction::Sleep => run("systemctl", &["suspend"]),
|
||||
CommandAction::Hibernate => run("systemctl", &["hibernate"]),
|
||||
CommandAction::Screen => bail!("screen action not supported in power backend"),
|
||||
_ => bail!("action not supported in power backend"),
|
||||
},
|
||||
"linux_sudoers" => match action {
|
||||
CommandAction::Shutdown => run("shutdown", &["-h", "now"]),
|
||||
CommandAction::Reboot => run("reboot", &[]),
|
||||
CommandAction::Sleep => run("systemctl", &["suspend"]),
|
||||
CommandAction::Hibernate => run("systemctl", &["hibernate"]),
|
||||
CommandAction::Screen => bail!("screen action not supported in power backend"),
|
||||
_ => bail!("action not supported in power backend"),
|
||||
},
|
||||
_ => bail!("unknown linux power backend"),
|
||||
}
|
||||
@@ -41,19 +46,35 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
|
||||
"1",
|
||||
],
|
||||
),
|
||||
CommandValue::On => run(
|
||||
"busctl",
|
||||
&[
|
||||
"--user",
|
||||
"set-property",
|
||||
"org.gnome.Mutter.DisplayConfig",
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
"org.gnome.Mutter.DisplayConfig",
|
||||
"PowerSaveMode",
|
||||
"i",
|
||||
"0",
|
||||
],
|
||||
),
|
||||
CommandValue::On => {
|
||||
// Retirer le mode veille ecran
|
||||
run(
|
||||
"busctl",
|
||||
&[
|
||||
"--user",
|
||||
"set-property",
|
||||
"org.gnome.Mutter.DisplayConfig",
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
"org.gnome.Mutter.DisplayConfig",
|
||||
"PowerSaveMode",
|
||||
"i",
|
||||
"0",
|
||||
],
|
||||
)?;
|
||||
// Simuler une activite utilisateur pour reveiller l'ecran
|
||||
let _ = Command::new("busctl")
|
||||
.args([
|
||||
"--user",
|
||||
"call",
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"/org/freedesktop/ScreenSaver",
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"SimulateUserActivity",
|
||||
])
|
||||
.output();
|
||||
Ok(())
|
||||
}
|
||||
_ => bail!("unsupported value for screen"),
|
||||
},
|
||||
"x11_xset" => match value {
|
||||
CommandValue::Off => {
|
||||
@@ -64,11 +85,212 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
|
||||
log_x11_env();
|
||||
run("xset", &["dpms", "force", "on"])
|
||||
}
|
||||
_ => bail!("unsupported value for screen"),
|
||||
},
|
||||
_ => bail!("unknown linux screen backend"),
|
||||
}
|
||||
}
|
||||
|
||||
// Regle le volume via wpctl (PipeWire). Volume en pourcentage 0-100.
|
||||
pub fn execute_audio(volume: u8) -> Result<()> {
|
||||
let level = format!("{:.2}", volume as f32 / 100.0);
|
||||
debug!(volume = volume, level = %level, "setting volume via wpctl");
|
||||
let mut cmd = Command::new("wpctl");
|
||||
cmd.args(["set-volume", "@DEFAULT_AUDIO_SINK@", &level]);
|
||||
inject_audio_env(&mut cmd);
|
||||
let output = cmd.output().context("failed to run wpctl set-volume")?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("wpctl set-volume failed ({}): {}", output.status, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Lit le volume actuel via wpctl. Retourne 0-100 ou None en cas d'erreur.
|
||||
pub fn read_volume() -> Option<u8> {
|
||||
let mut cmd = Command::new("wpctl");
|
||||
cmd.args(["get-volume", "@DEFAULT_AUDIO_SINK@"]);
|
||||
inject_audio_env(&mut cmd);
|
||||
let output = cmd.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
// Sortie format: "Volume: 0.60" ou "Volume: 0.60 [MUTED]"
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
let vol_str = raw.split_whitespace().nth(1)?;
|
||||
let vol_f: f32 = vol_str.parse().ok()?;
|
||||
Some((vol_f * 100.0).round() as u8)
|
||||
}
|
||||
|
||||
// Injecte XDG_RUNTIME_DIR/PIPEWIRE_RUNTIME_DIR pour les commandes audio.
|
||||
// Necessaire quand pilot tourne sans ces variables d'environnement (service systeme).
|
||||
fn inject_audio_env(cmd: &mut Command) {
|
||||
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
cmd.env("XDG_RUNTIME_DIR", &xdg);
|
||||
cmd.env("PIPEWIRE_RUNTIME_DIR", &xdg);
|
||||
return;
|
||||
}
|
||||
// Fallback: chercher /run/user/<uid> avec un socket pipewire
|
||||
if let Ok(entries) = std::fs::read_dir("/run/user") {
|
||||
for entry in entries.flatten() {
|
||||
let dir = entry.path();
|
||||
if dir.join("pipewire-0").exists() || dir.join("wayland-0").exists() {
|
||||
if let Some(dir_str) = dir.to_str() {
|
||||
cmd.env("XDG_RUNTIME_DIR", dir_str);
|
||||
cmd.env("PIPEWIRE_RUNTIME_DIR", dir_str);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Demarre une application configuree en transmettant les variables d'environnement GUI.
|
||||
pub fn execute_app_start(start_cmd: &str, start_args: &[String]) -> Result<()> {
|
||||
debug!(cmd = %start_cmd, args = ?start_args, "starting app");
|
||||
let args_ref: Vec<&str> = start_args.iter().map(|s| s.as_str()).collect();
|
||||
let mut cmd = Command::new(start_cmd);
|
||||
cmd.args(&args_ref);
|
||||
|
||||
// Transmettre les variables X11/Wayland/DBus pour les apps graphiques.
|
||||
// Si non definies (ex: service systemd), construire depuis XDG_RUNTIME_DIR.
|
||||
for var in &[
|
||||
"DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_SESSION_TYPE",
|
||||
"HOME",
|
||||
] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
|
||||
// XAUTHORITY: toujours chercher le fichier mutter Xwayland, meme si DISPLAY est deja
|
||||
// present dans l'environnement. Le suffix est aleatoire et change a chaque session,
|
||||
// donc on ne peut pas le hardcoder dans le service systemd.
|
||||
if std::env::var("XAUTHORITY").is_err() {
|
||||
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
|
||||
.unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
let runtime_path = Path::new(&runtime_dir);
|
||||
|
||||
// Fallback complet si WAYLAND_DISPLAY aussi absent
|
||||
if std::env::var("WAYLAND_DISPLAY").is_err() && runtime_path.join("wayland-0").exists() {
|
||||
cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
|
||||
cmd.env("WAYLAND_DISPLAY", "wayland-0");
|
||||
cmd.env("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}/bus", runtime_dir));
|
||||
}
|
||||
|
||||
// Injecter DISPLAY + XAUTHORITY pour XWayland (apps Electron/Flatpak)
|
||||
if let Ok(auth) = find_mutter_xauth(runtime_path) {
|
||||
cmd.env("DISPLAY", ":0");
|
||||
cmd.env("XAUTHORITY", &auth);
|
||||
debug!(xauthority = %auth.display(), "xwayland auth injected");
|
||||
} else {
|
||||
cmd.env("DISPLAY", ":0");
|
||||
debug!("DISPLAY=:0 injected (no xauthority found)");
|
||||
}
|
||||
}
|
||||
|
||||
cmd.spawn()
|
||||
.with_context(|| format!("failed to start {start_cmd}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Arrete une application via pkill -9 (SIGKILL) pour garantir l'arret immediat.
|
||||
pub fn execute_app_stop(process_check: &str) -> Result<()> {
|
||||
debug!(process = %process_check, "stopping app via pkill -9");
|
||||
Command::new("pkill")
|
||||
.args(["-9", "-i", "-f", process_check])
|
||||
.status()
|
||||
.with_context(|| format!("failed to run pkill for {process_check}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Verifie si une application est en cours d'execution via pgrep.
|
||||
pub fn app_is_running(process_check: &str) -> bool {
|
||||
Command::new("pgrep")
|
||||
.args(["-f", process_check])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Connecte ou deconnecte un appareil Bluetooth via bluetoothctl.
|
||||
pub fn execute_bluetooth(mac: &str, connect: bool) -> Result<()> {
|
||||
let action = if connect { "connect" } else { "disconnect" };
|
||||
debug!(mac = %mac, action = %action, "bluetooth command");
|
||||
run("bluetoothctl", &[action, mac])
|
||||
}
|
||||
|
||||
// Verifie si un appareil Bluetooth est connecte via bluetoothctl info.
|
||||
pub fn bluetooth_is_connected(mac: &str) -> bool {
|
||||
let output = Command::new("bluetoothctl")
|
||||
.args(["info", mac])
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.any(|line| line.trim().starts_with("Connected:") && line.contains("yes"))
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
const SLEEP_INHIBIT_PID_FILE: &str = "/tmp/pilot-sleep-inhibit.pid";
|
||||
|
||||
// Active ou desactive l'inhibition de mise en veille via systemd-inhibit.
|
||||
pub fn execute_inhibit_sleep(enable: bool) -> Result<()> {
|
||||
if enable {
|
||||
let child = Command::new("systemd-inhibit")
|
||||
.args([
|
||||
"--what=sleep:idle",
|
||||
"--who=Pilot",
|
||||
"--why=HomeAssistant inhibit",
|
||||
"--mode=block",
|
||||
"sleep", "infinity",
|
||||
])
|
||||
.spawn()
|
||||
.context("failed to start systemd-inhibit")?;
|
||||
let pid = child.id();
|
||||
std::fs::write(SLEEP_INHIBIT_PID_FILE, pid.to_string())
|
||||
.context("failed to write inhibit PID file")?;
|
||||
debug!(pid = pid, "sleep inhibitor started");
|
||||
} else {
|
||||
if let Ok(pid_str) = std::fs::read_to_string(SLEEP_INHIBIT_PID_FILE) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||
run("kill", &[&pid.to_string()])?;
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(SLEEP_INHIBIT_PID_FILE);
|
||||
debug!("sleep inhibitor stopped");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Verifie si l'inhibition de veille est active.
|
||||
pub fn is_sleep_inhibited() -> bool {
|
||||
if let Ok(pid_str) = std::fs::read_to_string(SLEEP_INHIBIT_PID_FILE) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||
return Path::new(&format!("/proc/{}", pid)).exists();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Lance la mise a jour systeme via apt (sudo apt update && apt upgrade -y).
|
||||
// Necessite une entree sudoers NOPASSWD pour /usr/bin/apt.
|
||||
pub fn execute_system_update() -> Result<()> {
|
||||
debug!("running apt update");
|
||||
run("sudo", &["apt", "update"])?;
|
||||
debug!("running apt upgrade");
|
||||
run("sudo", &["apt", "upgrade", "-y"])
|
||||
}
|
||||
|
||||
fn run(cmd: &str, args: &[&str]) -> Result<()> {
|
||||
debug!(%cmd, args = ?args, "running command");
|
||||
let output = Command::new(cmd)
|
||||
@@ -83,6 +305,20 @@ fn run(cmd: &str, args: &[&str]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Cherche le fichier .mutter-Xwaylandauth.* dans le runtime dir pour XWayland.
|
||||
fn find_mutter_xauth(runtime_dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let entries = std::fs::read_dir(runtime_dir)
|
||||
.context("read runtime dir")?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with(".mutter-Xwaylandauth") {
|
||||
return Ok(entry.path());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("mutter Xwayland auth file not found")
|
||||
}
|
||||
|
||||
fn log_x11_env() {
|
||||
let display_env = std::env::var("DISPLAY").unwrap_or_default();
|
||||
let xauth_env = std::env::var("XAUTHORITY").unwrap_or_default();
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::commands::{CommandAction, CommandValue};
|
||||
pub mod linux;
|
||||
pub mod windows;
|
||||
|
||||
// Execute une commande d'alimentation (shutdown/reboot/sleep).
|
||||
// Execute une commande d'alimentation (shutdown/reboot/sleep/hibernate).
|
||||
pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
|
||||
if cfg!(target_os = "windows") {
|
||||
windows::execute_power(backend, action)
|
||||
@@ -24,3 +24,93 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
|
||||
linux::execute_screen(backend, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Regle le volume systeme (0-100 via wpctl).
|
||||
pub fn execute_audio(volume: u8) -> Result<()> {
|
||||
if cfg!(target_os = "windows") {
|
||||
anyhow::bail!("audio control not implemented on Windows")
|
||||
} else {
|
||||
linux::execute_audio(volume)
|
||||
}
|
||||
}
|
||||
|
||||
// Lit le volume actuel (0-100).
|
||||
pub fn read_volume() -> Option<u8> {
|
||||
if cfg!(target_os = "windows") {
|
||||
None
|
||||
} else {
|
||||
linux::read_volume()
|
||||
}
|
||||
}
|
||||
|
||||
// Demarre une application.
|
||||
pub fn execute_app_start(start_cmd: &str, start_args: &[String]) -> Result<()> {
|
||||
if cfg!(target_os = "windows") {
|
||||
anyhow::bail!("app control not implemented on Windows")
|
||||
} else {
|
||||
linux::execute_app_start(start_cmd, start_args)
|
||||
}
|
||||
}
|
||||
|
||||
// Arrete une application via son nom de processus.
|
||||
pub fn execute_app_stop(process_check: &str) -> Result<()> {
|
||||
if cfg!(target_os = "windows") {
|
||||
anyhow::bail!("app control not implemented on Windows")
|
||||
} else {
|
||||
linux::execute_app_stop(process_check)
|
||||
}
|
||||
}
|
||||
|
||||
// Verifie si une application est en cours d'execution.
|
||||
pub fn app_is_running(process_check: &str) -> bool {
|
||||
if cfg!(target_os = "windows") {
|
||||
false
|
||||
} else {
|
||||
linux::app_is_running(process_check)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecte ou deconnecte un appareil Bluetooth.
|
||||
pub fn execute_bluetooth(mac: &str, connect: bool) -> Result<()> {
|
||||
if cfg!(target_os = "windows") {
|
||||
anyhow::bail!("bluetooth control not implemented on Windows")
|
||||
} else {
|
||||
linux::execute_bluetooth(mac, connect)
|
||||
}
|
||||
}
|
||||
|
||||
// Verifie si un appareil Bluetooth est connecte.
|
||||
pub fn bluetooth_is_connected(mac: &str) -> bool {
|
||||
if cfg!(target_os = "windows") {
|
||||
false
|
||||
} else {
|
||||
linux::bluetooth_is_connected(mac)
|
||||
}
|
||||
}
|
||||
|
||||
// Active/desactive l'inhibition de veille.
|
||||
pub fn execute_inhibit_sleep(enable: bool) -> Result<()> {
|
||||
if cfg!(target_os = "windows") {
|
||||
anyhow::bail!("sleep inhibit not implemented on Windows")
|
||||
} else {
|
||||
linux::execute_inhibit_sleep(enable)
|
||||
}
|
||||
}
|
||||
|
||||
// Verifie si la veille est inhibee.
|
||||
pub fn is_sleep_inhibited() -> bool {
|
||||
if cfg!(target_os = "windows") {
|
||||
false
|
||||
} else {
|
||||
linux::is_sleep_inhibited()
|
||||
}
|
||||
}
|
||||
|
||||
// Lance la mise a jour systeme.
|
||||
pub fn execute_system_update() -> Result<()> {
|
||||
if cfg!(target_os = "windows") {
|
||||
anyhow::bail!("system update not implemented on Windows")
|
||||
} else {
|
||||
linux::execute_system_update()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,27 @@ impl Runtime {
|
||||
mqtt::subscribe_commands(&client, &self.config).await?;
|
||||
}
|
||||
|
||||
// Charger les chaines M3U pour les apps avec channels_m3u
|
||||
let mut channels_map: HashMap<String, Vec<(String, String)>> = HashMap::new();
|
||||
for app in &self.config.apps {
|
||||
if let Some(m3u_path) = &app.channels_m3u {
|
||||
let channels = crate::m3u::parse_file(m3u_path);
|
||||
if !channels.is_empty() {
|
||||
info!(app = %app.name, count = channels.len(), m3u = %m3u_path, "loaded M3U channels");
|
||||
channels_map.insert(app.name.clone(), channels);
|
||||
} else {
|
||||
warn!(app = %app.name, m3u = %m3u_path, "M3U file empty or not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut current_channels: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Demarrer le listener de touches si configure
|
||||
let (keycode_tx, mut keycode_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
if self.config.keycodes.enabled && !self.config.keycodes.devices.is_empty() {
|
||||
crate::keycode::start_listener(self.config.keycodes.devices.clone(), keycode_tx);
|
||||
}
|
||||
|
||||
tracing::info!("entering main event loop");
|
||||
|
||||
let mut telemetry = if self.config.features.telemetry.enabled {
|
||||
@@ -128,7 +149,28 @@ impl Runtime {
|
||||
self.config.publish.heartbeat_s,
|
||||
));
|
||||
let mut stats_tick = interval(Duration::from_secs(60));
|
||||
// Ticks rapides pour volume (2s) et etat des apps (2s)
|
||||
let mut volume_tick = interval(Duration::from_secs(2));
|
||||
let mut app_state_tick = interval(Duration::from_secs(2));
|
||||
// Sync lent (30s) : pgrep pour detecter les apps demarrees/arretees en dehors de pilot
|
||||
let mut app_sync_tick = interval(Duration::from_secs(30));
|
||||
let mut last_exec: HashMap<CommandAction, std::time::Instant> = HashMap::new();
|
||||
// Etat interne des apps: pilot suit lui-meme l'etat ON/OFF des apps qu'il gere.
|
||||
// Cela evite le probleme de race condition avec pgrep apres un stop SIGKILL.
|
||||
// Un sync lent (30s) via pgrep permet de detecter les apps demarrees/arretees en dehors de pilot.
|
||||
let mut app_states: HashMap<String, bool> = HashMap::new();
|
||||
for app in &self.config.apps {
|
||||
if app.enabled {
|
||||
app_states.insert(app.name.clone(), platform::app_is_running(&app.process_check));
|
||||
}
|
||||
}
|
||||
|
||||
// Detection double appui sur les touches liees (key_bindings).
|
||||
// pending_key = (nom_touche, index_binding) en attente de confirmation single/double.
|
||||
let mut pending_key: Option<(String, usize)> = None;
|
||||
// Timer remis a zero a chaque premier appui — expire => single press.
|
||||
let key_press_timer = tokio::time::sleep(tokio::time::Duration::from_millis(u64::MAX));
|
||||
tokio::pin!(key_press_timer);
|
||||
|
||||
let shutdown = tokio::signal::ctrl_c();
|
||||
tokio::pin!(shutdown);
|
||||
@@ -149,19 +191,33 @@ impl Runtime {
|
||||
}
|
||||
|
||||
if !due.is_empty() {
|
||||
// Metriques speciales gerees directement dans le runtime
|
||||
let power_state_due = due.remove("power_state");
|
||||
let volume_due = due.remove("volume_level");
|
||||
|
||||
// Collecte et publie les metriques standard
|
||||
let metrics = telemetry.as_mut().unwrap().read(&due);
|
||||
for (name, value) in metrics {
|
||||
if let Err(err) = mqtt::publish_state(&client, &self.config, &name, &value).await {
|
||||
warn!(error = %err, "publish state failed");
|
||||
}
|
||||
}
|
||||
|
||||
if power_state_due && enabled_metrics.contains("power_state") {
|
||||
let current = detect_power_state();
|
||||
if let Err(err) = mqtt::publish_state(&client, &self.config, "power_state", ¤t).await {
|
||||
warn!(error = %err, "publish power_state failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Volume via wpctl (capteur telemetrie)
|
||||
if volume_due && enabled_metrics.contains("volume_level") {
|
||||
if let Some(vol) = platform::read_volume() {
|
||||
if let Err(err) = mqtt::publish_state(&client, &self.config, "volume_level", &vol.to_string()).await {
|
||||
warn!(error = %err, "publish volume_level failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = heartbeat_tick.tick() => {
|
||||
@@ -169,6 +225,28 @@ impl Runtime {
|
||||
if let Err(err) = mqtt::publish_status(&client, &self.config, &status).await {
|
||||
warn!(error = %err, "publish status failed");
|
||||
}
|
||||
// Bluetooth uniquement dans le heartbeat (moins critique)
|
||||
update_bluetooth_states(&client, &self.config).await;
|
||||
}
|
||||
_ = volume_tick.tick() => {
|
||||
// Actualisation rapide du volume (5s) pour reflechir les changements locaux
|
||||
if let Some(vol) = platform::read_volume() {
|
||||
let vol_str = vol.to_string();
|
||||
let _ = mqtt::publish_state(&client, &self.config, "volume_level", &vol_str).await;
|
||||
let _ = mqtt::publish_switch_state(&client, &self.config, "volume", &vol_str).await;
|
||||
}
|
||||
}
|
||||
_ = app_state_tick.tick() => {
|
||||
publish_app_states(&client, &self.config, &app_states).await;
|
||||
}
|
||||
_ = app_sync_tick.tick() => {
|
||||
// Sync pgrep pour detecter les changements exterieurs
|
||||
for app in &self.config.apps {
|
||||
if app.enabled {
|
||||
let running = platform::app_is_running(&app.process_check);
|
||||
app_states.insert(app.name.clone(), running);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = stats_tick.tick() => {
|
||||
let published = mqtt::take_publish_count();
|
||||
@@ -181,12 +259,84 @@ impl Runtime {
|
||||
&client,
|
||||
&self.config,
|
||||
&mut last_exec,
|
||||
&channels_map,
|
||||
&mut current_channels,
|
||||
&mut app_states,
|
||||
&topic,
|
||||
&payload,
|
||||
).await {
|
||||
warn!(error = %err, "command handling failed");
|
||||
}
|
||||
}
|
||||
// Timer double appui expire → single press confirme
|
||||
_ = &mut key_press_timer, if pending_key.is_some() => {
|
||||
if let Some((key, idx)) = pending_key.take() {
|
||||
let binding = &self.config.key_bindings.bindings[idx];
|
||||
if let Some(action) = &binding.single_press {
|
||||
execute_key_binding_action(&client, &self.config, &mut app_states, action).await;
|
||||
info!(key = %key, action = %action, "key binding: single press");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(key) = keycode_rx.recv() => {
|
||||
info!(key = %key, "keycode received");
|
||||
let _ = mqtt::publish_state(&client, &self.config, "keycode", &key).await;
|
||||
|
||||
// Detecter si cette touche est liee a un binding
|
||||
let binding_idx = if self.config.key_bindings.enabled {
|
||||
let idx = self.config.key_bindings.bindings.iter().position(|b| b.key == key);
|
||||
info!(key = %key, found = idx.is_some(), "keycode: binding lookup");
|
||||
idx
|
||||
} else {
|
||||
info!("keycode: key_bindings disabled");
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(idx) = binding_idx {
|
||||
let binding = &self.config.key_bindings.bindings[idx];
|
||||
if let Some((ref pk, _)) = pending_key {
|
||||
if *pk == key {
|
||||
// Double appui detecte
|
||||
pending_key = None;
|
||||
let action = binding.double_press.clone();
|
||||
info!(key = %key, action = ?action, "key binding: DOUBLE PRESS detected");
|
||||
if let Some(action) = action {
|
||||
execute_key_binding_action(&client, &self.config, &mut app_states, &action).await;
|
||||
}
|
||||
} else {
|
||||
// Touche differente : fire single press de l'ancienne touche
|
||||
let (old_key, old_idx) = pending_key.take().unwrap();
|
||||
let old_binding = &self.config.key_bindings.bindings[old_idx];
|
||||
if let Some(action) = old_binding.single_press.clone() {
|
||||
info!(key = %old_key, action = %action, "key binding: single press (interrupted by other key)");
|
||||
execute_key_binding_action(&client, &self.config, &mut app_states, &action).await;
|
||||
}
|
||||
// Armer le nouveau pending
|
||||
pending_key = Some((key.clone(), idx));
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ tokio::time::Duration::from_millis(binding.double_press_ms);
|
||||
key_press_timer.as_mut().reset(deadline);
|
||||
info!(key = %key, ms = binding.double_press_ms, "key binding: first press, timer armed");
|
||||
}
|
||||
} else {
|
||||
// Premier appui : armer le timer
|
||||
pending_key = Some((key.clone(), idx));
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ tokio::time::Duration::from_millis(binding.double_press_ms);
|
||||
key_press_timer.as_mut().reset(deadline);
|
||||
info!(key = %key, ms = binding.double_press_ms, "key binding: first press, timer armed");
|
||||
}
|
||||
} else {
|
||||
// Touche non liee : navigation chaines
|
||||
handle_channel_nav_key(
|
||||
&client,
|
||||
&self.config,
|
||||
&channels_map,
|
||||
&mut current_channels,
|
||||
&key,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
_ = &mut shutdown => {
|
||||
if let Err(err) = mqtt::publish_availability(&client, &self.config, false).await {
|
||||
warn!(error = %err, "publish availability offline failed");
|
||||
@@ -258,10 +408,42 @@ fn capabilities(cfg: &Config) -> Capabilities {
|
||||
|
||||
let mut commands = Vec::new();
|
||||
if cfg.features.commands.enabled {
|
||||
commands.push("shutdown".to_string());
|
||||
commands.push("reboot".to_string());
|
||||
commands.push("sleep".to_string());
|
||||
commands.push("screen".to_string());
|
||||
let base_cmds = ["shutdown", "reboot", "sleep", "hibernate", "screen", "volume", "system_update"];
|
||||
for cmd in &base_cmds {
|
||||
if cfg.features.commands.allowlist.is_empty()
|
||||
|| cfg.features.commands.allowlist.iter().any(|a| a == *cmd)
|
||||
{
|
||||
commands.push(cmd.to_string());
|
||||
}
|
||||
}
|
||||
for app in &cfg.apps {
|
||||
if app.enabled {
|
||||
let key = format!("app_{}", app.name);
|
||||
if cfg.features.commands.allowlist.is_empty()
|
||||
|| cfg.features.commands.allowlist.iter().any(|a| a == &key)
|
||||
{
|
||||
commands.push(key);
|
||||
}
|
||||
if app.channels_m3u.is_some() {
|
||||
let channel_key = format!("{}_channel", app.name);
|
||||
if cfg.features.commands.allowlist.is_empty()
|
||||
|| cfg.features.commands.allowlist.iter().any(|a| a == &channel_key)
|
||||
{
|
||||
commands.push(channel_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.bluetooth.enabled {
|
||||
for dev in &cfg.bluetooth.devices {
|
||||
let key = format!("bluetooth_{}", dev.name);
|
||||
if cfg.features.commands.allowlist.is_empty()
|
||||
|| cfg.features.commands.allowlist.iter().any(|a| a == &key)
|
||||
{
|
||||
commands.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Capabilities {
|
||||
@@ -346,11 +528,15 @@ fn detect_power_state_logind() -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Traite une commande entrante (topic + payload) avec cooldown et dry-run.
|
||||
async fn handle_command(
|
||||
client: &rumqttc::AsyncClient,
|
||||
cfg: &Config,
|
||||
last_exec: &mut HashMap<CommandAction, std::time::Instant>,
|
||||
channels_map: &HashMap<String, Vec<(String, String)>>,
|
||||
current_channels: &mut HashMap<String, String>,
|
||||
app_states: &mut HashMap<String, bool>,
|
||||
topic: &str,
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -358,72 +544,368 @@ async fn handle_command(
|
||||
let value = commands::parse_value(payload)?;
|
||||
debug!(%topic, ?action, ?value, "command received");
|
||||
|
||||
if !commands::allowlist_allows(&cfg.features.commands.allowlist, action) {
|
||||
if !commands::allowlist_allows(&cfg.features.commands.allowlist, &action) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !commands::allow_command(last_exec, cfg.features.commands.cooldown_s, action) {
|
||||
if !commands::allow_command(last_exec, cfg.features.commands.cooldown_s, action.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cfg.features.commands.dry_run {
|
||||
commands::execute_dry_run(action, value)?;
|
||||
publish_command_state(client, cfg, action, value).await?;
|
||||
commands::execute_dry_run(&action, &value)?;
|
||||
publish_command_state(client, cfg, &action, &value).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match action {
|
||||
match &action {
|
||||
CommandAction::Shutdown => {
|
||||
if matches!(value, CommandValue::Off) {
|
||||
platform::execute_power(&backend_power(cfg), action)?;
|
||||
if matches!(value, CommandValue::On) {
|
||||
// Reset immediat avant execution (la machine va s'eteindre)
|
||||
mqtt::publish_switch_state(client, cfg, "shutdown", "OFF").await?;
|
||||
mqtt::publish_state(client, cfg, "power_state", "off").await?;
|
||||
publish_command_state(client, cfg, action, value).await?;
|
||||
platform::execute_power(&backend_power(cfg), action.clone())?;
|
||||
}
|
||||
}
|
||||
CommandAction::Reboot => {
|
||||
if matches!(value, CommandValue::Off) {
|
||||
platform::execute_power(&backend_power(cfg), action)?;
|
||||
if matches!(value, CommandValue::On) {
|
||||
mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await?;
|
||||
mqtt::publish_state(client, cfg, "power_state", "on").await?;
|
||||
publish_command_state(client, cfg, action, value).await?;
|
||||
platform::execute_power(&backend_power(cfg), action.clone())?;
|
||||
}
|
||||
}
|
||||
CommandAction::Sleep => {
|
||||
if matches!(value, CommandValue::Off) {
|
||||
platform::execute_power(&backend_power(cfg), action)?;
|
||||
if matches!(value, CommandValue::On) {
|
||||
mqtt::publish_switch_state(client, cfg, "sleep", "OFF").await?;
|
||||
mqtt::publish_state(client, cfg, "power_state", "sleep").await?;
|
||||
publish_command_state(client, cfg, action, value).await?;
|
||||
platform::execute_power(&backend_power(cfg), action.clone())?;
|
||||
}
|
||||
}
|
||||
CommandAction::Hibernate => {
|
||||
if matches!(value, CommandValue::On) {
|
||||
mqtt::publish_switch_state(client, cfg, "hibernate", "OFF").await?;
|
||||
mqtt::publish_state(client, cfg, "power_state", "sleep").await?;
|
||||
platform::execute_power(&backend_power(cfg), action.clone())?;
|
||||
}
|
||||
}
|
||||
CommandAction::Screen => {
|
||||
let backend = backend_screen(cfg);
|
||||
debug!(backend = %backend, ?value, "executing screen command");
|
||||
platform::execute_screen(&backend, value)?;
|
||||
publish_command_state(client, cfg, action, value).await?;
|
||||
platform::execute_screen(&backend, value.clone())?;
|
||||
publish_command_state(client, cfg, &action, &value).await?;
|
||||
}
|
||||
CommandAction::Volume => {
|
||||
if let CommandValue::Number(vol) = value {
|
||||
platform::execute_audio(vol)?;
|
||||
mqtt::publish_switch_state(client, cfg, "volume", &vol.to_string()).await?;
|
||||
}
|
||||
}
|
||||
CommandAction::App(name) => {
|
||||
let app = cfg.apps.iter().find(|a| a.name == *name && a.enabled);
|
||||
if let Some(app) = app {
|
||||
let key = format!("app_{}", name);
|
||||
match value {
|
||||
CommandValue::On => {
|
||||
platform::execute_app_start(&app.start_cmd, &app.start_args)?;
|
||||
app_states.insert(name.clone(), true);
|
||||
mqtt::publish_switch_state(client, cfg, &key, "ON").await?;
|
||||
}
|
||||
CommandValue::Off => {
|
||||
platform::execute_app_stop(&app.process_check)?;
|
||||
app_states.insert(name.clone(), false);
|
||||
mqtt::publish_switch_state(client, cfg, &key, "OFF").await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
CommandAction::Bluetooth(name) => {
|
||||
if cfg.bluetooth.enabled {
|
||||
let dev = cfg.bluetooth.devices.iter().find(|d| d.name == *name);
|
||||
if let Some(dev) = dev {
|
||||
let connect = matches!(value, CommandValue::On);
|
||||
platform::execute_bluetooth(&dev.mac, connect)?;
|
||||
let state = if platform::bluetooth_is_connected(&dev.mac) { "ON" } else { "OFF" };
|
||||
let key = format!("bluetooth_{}", name);
|
||||
mqtt::publish_switch_state(client, cfg, &key, state).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
CommandAction::SystemUpdate => {
|
||||
if matches!(value, CommandValue::On) {
|
||||
platform::execute_system_update()?;
|
||||
mqtt::publish_switch_state(client, cfg, "system_update", "OFF").await?;
|
||||
}
|
||||
}
|
||||
CommandAction::InhibitSleep => {
|
||||
let enable = matches!(value, CommandValue::On);
|
||||
platform::execute_inhibit_sleep(enable)?;
|
||||
let state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" };
|
||||
mqtt::publish_switch_state(client, cfg, "inhibit_sleep", state).await?;
|
||||
}
|
||||
CommandAction::TvChannel(app_name) => {
|
||||
if let CommandValue::Text(channel_name) = &value {
|
||||
if let Some(app) = cfg.apps.iter().find(|a| a.name == *app_name && a.enabled) {
|
||||
if let Some(m3u_path) = &app.channels_m3u {
|
||||
if let Some(channels) = channels_map.get(app_name.as_str()) {
|
||||
if let Some((_, url)) = channels.iter().find(|(n, _)| n == channel_name) {
|
||||
let url = url.clone();
|
||||
// Construire les args sans le chemin M3U, ajouter l'URL de la chaine
|
||||
let filtered_args: Vec<String> = app.start_args.iter()
|
||||
.filter(|a| a.as_str() != m3u_path.as_str())
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut channel_args = filtered_args;
|
||||
channel_args.push(url);
|
||||
// Arreter l'instance existante, relancer sur la nouvelle chaine
|
||||
platform::execute_app_stop(&app.process_check)?;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
platform::execute_app_start(&app.start_cmd, &channel_args)?;
|
||||
let channel_key = format!("{}_channel", app_name);
|
||||
mqtt::publish_switch_state(client, cfg, &channel_key, channel_name).await?;
|
||||
let app_key = format!("app_{}", app_name);
|
||||
mqtt::publish_switch_state(client, cfg, &app_key, "ON").await?;
|
||||
current_channels.insert(app_name.clone(), channel_name.clone());
|
||||
} else {
|
||||
warn!(channel = %channel_name, "channel not found in M3U");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Publie l'etat initial des switches HA (par defaut ON).
|
||||
// Publie l'etat initial des switches HA.
|
||||
// Commandes momentanees (shutdown/reboot/sleep/hibernate/system_update) : OFF par defaut.
|
||||
// Commandes avec etat reel (screen, inhibit_sleep, apps, bluetooth) : lues depuis le systeme.
|
||||
async fn publish_initial_command_states(client: &rumqttc::AsyncClient, cfg: &Config) {
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "shutdown", "ON").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "reboot", "ON").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "sleep", "ON").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "shutdown", "OFF").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "sleep", "OFF").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "hibernate", "OFF").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "screen", "ON").await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "system_update", "OFF").await;
|
||||
let inhibit_state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" };
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "inhibit_sleep", inhibit_state).await;
|
||||
|
||||
// Volume initial
|
||||
if let Some(vol) = platform::read_volume() {
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "volume", &vol.to_string()).await;
|
||||
}
|
||||
|
||||
// Etat initial des apps
|
||||
for app in &cfg.apps {
|
||||
if !app.enabled { continue; }
|
||||
let running = platform::app_is_running(&app.process_check);
|
||||
let state = if running { "ON" } else { "OFF" };
|
||||
let key = format!("app_{}", app.name);
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
|
||||
}
|
||||
|
||||
// Etat initial Bluetooth
|
||||
if cfg.bluetooth.enabled {
|
||||
for dev in &cfg.bluetooth.devices {
|
||||
let connected = platform::bluetooth_is_connected(&dev.mac);
|
||||
let state = if connected { "ON" } else { "OFF" };
|
||||
let key = format!("bluetooth_{}", dev.name);
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publie l'etat des apps depuis l'etat interne (pas de pgrep — evite les races conditions).
|
||||
// L'etat interne est mis a jour quand pilot demarre/arrete une app.
|
||||
// Un sync pgrep toutes les 30s detecte les changements exterieurs.
|
||||
async fn publish_app_states(
|
||||
client: &rumqttc::AsyncClient,
|
||||
cfg: &Config,
|
||||
app_states: &HashMap<String, bool>,
|
||||
) {
|
||||
for app in &cfg.apps {
|
||||
if !app.enabled {
|
||||
continue;
|
||||
}
|
||||
let key = format!("app_{}", app.name);
|
||||
if !cfg.features.commands.allowlist.is_empty()
|
||||
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let running = app_states.get(&app.name).copied().unwrap_or(false);
|
||||
let state = if running { "ON" } else { "OFF" };
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
|
||||
}
|
||||
|
||||
// Etat inhibition veille
|
||||
let inhibit_state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" };
|
||||
let _ = mqtt::publish_switch_state(client, cfg, "inhibit_sleep", inhibit_state).await;
|
||||
}
|
||||
|
||||
// Verifie et publie l'etat Bluetooth (appelee au heartbeat uniquement — plus lent).
|
||||
async fn update_bluetooth_states(client: &rumqttc::AsyncClient, cfg: &Config) {
|
||||
if !cfg.bluetooth.enabled {
|
||||
return;
|
||||
}
|
||||
for dev in &cfg.bluetooth.devices {
|
||||
let key = format!("bluetooth_{}", dev.name);
|
||||
if !cfg.features.commands.allowlist.is_empty()
|
||||
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let connected = platform::bluetooth_is_connected(&dev.mac);
|
||||
let state = if connected { "ON" } else { "OFF" };
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute l'action d'un key binding.
|
||||
// Actions supportees :
|
||||
// "vacuum_tube", "livebox_tv", ... → toggle app (via etat interne app_states)
|
||||
// "key:28" → injecte le keycode 28 (Enter) via ydotool
|
||||
// Met a jour app_states directement (pas de retour).
|
||||
async fn execute_key_binding_action(
|
||||
client: &rumqttc::AsyncClient,
|
||||
cfg: &Config,
|
||||
app_states: &mut HashMap<String, bool>,
|
||||
action: &str,
|
||||
) {
|
||||
// Action "key:<code>" : injection de touche via ydotool
|
||||
if let Some(code_str) = action.strip_prefix("key:") {
|
||||
info!(action = %action, "key binding: injecting key via ydotool");
|
||||
let result = std::process::Command::new("ydotool")
|
||||
.args(["key", code_str])
|
||||
.output();
|
||||
match result {
|
||||
Ok(out) if out.status.success() => {}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
warn!(code = %code_str, stderr = %stderr, "ydotool key failed");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(code = %code_str, error = %e, "ydotool not available");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(app) = cfg.apps.iter().find(|a| a.name == action && a.enabled) {
|
||||
let key = format!("app_{}", action);
|
||||
// Utilise l'etat interne pour eviter les races conditions avec pgrep
|
||||
let is_running = app_states.get(action).copied().unwrap_or(false);
|
||||
if is_running {
|
||||
info!(app = %action, "key binding: stopping app");
|
||||
if let Err(e) = platform::execute_app_stop(&app.process_check) {
|
||||
warn!(app = %action, error = %e, "key binding: failed to stop app");
|
||||
}
|
||||
app_states.insert(action.to_string(), false);
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &key, "OFF").await;
|
||||
} else {
|
||||
info!(app = %action, "key binding: starting app");
|
||||
match platform::execute_app_start(&app.start_cmd, &app.start_args) {
|
||||
Ok(_) => {
|
||||
app_states.insert(action.to_string(), true);
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &key, "ON").await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(app = %action, error = %e, "key binding: failed to start app");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(action = %action, "key binding: unknown app action");
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation chaine via touche de telecommande (prog+/prog-).
|
||||
// Cherche l'app dont channel_next_key ou channel_prev_key correspond a la touche recue,
|
||||
// calcule le nouvel index (wrapping), relance le lecteur sur la nouvelle chaine.
|
||||
async fn handle_channel_nav_key(
|
||||
client: &rumqttc::AsyncClient,
|
||||
cfg: &Config,
|
||||
channels_map: &HashMap<String, Vec<(String, String)>>,
|
||||
current_channels: &mut HashMap<String, String>,
|
||||
key: &str,
|
||||
) {
|
||||
for app in &cfg.apps {
|
||||
if !app.enabled {
|
||||
continue;
|
||||
}
|
||||
let is_next = app.channel_next_key.as_deref().map_or(false, |k| k == key);
|
||||
let is_prev = app.channel_prev_key.as_deref().map_or(false, |k| k == key);
|
||||
if !is_next && !is_prev {
|
||||
continue;
|
||||
}
|
||||
let channels = match channels_map.get(&app.name) {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => continue,
|
||||
};
|
||||
if !platform::app_is_running(&app.process_check) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current = current_channels.get(&app.name).cloned();
|
||||
let idx = current
|
||||
.as_deref()
|
||||
.and_then(|name| channels.iter().position(|(n, _)| n == name))
|
||||
.unwrap_or(0);
|
||||
let new_idx = if is_next {
|
||||
(idx + 1) % channels.len()
|
||||
} else if idx == 0 {
|
||||
channels.len() - 1
|
||||
} else {
|
||||
idx - 1
|
||||
};
|
||||
|
||||
let (new_channel_name, new_url) = &channels[new_idx];
|
||||
let url = new_url.clone();
|
||||
let new_channel_name = new_channel_name.clone();
|
||||
|
||||
// Reconstruire les args sans le chemin M3U, ajouter l'URL directe
|
||||
let filtered_args: Vec<String> = if let Some(m3u_path) = &app.channels_m3u {
|
||||
app.start_args.iter().filter(|a| a.as_str() != m3u_path.as_str()).cloned().collect()
|
||||
} else {
|
||||
app.start_args.clone()
|
||||
};
|
||||
let mut channel_args = filtered_args;
|
||||
channel_args.push(url);
|
||||
|
||||
if let Err(e) = platform::execute_app_stop(&app.process_check) {
|
||||
warn!(error = %e, "channel nav: failed to stop app");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if let Err(e) = platform::execute_app_start(&app.start_cmd, &channel_args) {
|
||||
warn!(error = %e, "channel nav: failed to start app");
|
||||
continue;
|
||||
}
|
||||
|
||||
let channel_key = format!("{}_channel", app.name);
|
||||
let app_key = format!("app_{}", app.name);
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &channel_key, &new_channel_name).await;
|
||||
let _ = mqtt::publish_switch_state(client, cfg, &app_key, "ON").await;
|
||||
current_channels.insert(app.name.clone(), new_channel_name.clone());
|
||||
info!(app = %app.name, channel = %new_channel_name, direction = if is_next { "next" } else { "prev" }, "channel navigation");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Publie l'etat d'une commande pour Home Assistant.
|
||||
async fn publish_command_state(
|
||||
client: &rumqttc::AsyncClient,
|
||||
cfg: &Config,
|
||||
action: CommandAction,
|
||||
value: CommandValue,
|
||||
action: &CommandAction,
|
||||
value: &CommandValue,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = match value {
|
||||
CommandValue::On => "ON",
|
||||
CommandValue::Off => "OFF",
|
||||
CommandValue::On => "ON".to_string(),
|
||||
CommandValue::Off => "OFF".to_string(),
|
||||
CommandValue::Number(n) => n.to_string(),
|
||||
CommandValue::Text(s) => s.clone(),
|
||||
};
|
||||
let name = commands::action_name(action);
|
||||
mqtt::publish_switch_state(client, cfg, name, state).await
|
||||
mqtt::publish_switch_state(client, cfg, &name, &state).await
|
||||
}
|
||||
|
||||
252
scripts/install_pilot.sh
Executable file
252
scripts/install_pilot.sh
Executable 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 "══════════════════════════════════════════════════"
|
||||
Reference in New Issue
Block a user