La domotique avec capy : piloter les lampes et prises de la maison depuis Python ou le terminal, quelle que soit la marque, avec toujours les mêmes mots : on(), off(), brightness(30), color("orange"), white("chaud"). Sans dépendance, sans compte en ligne : tout se passe sur ton réseau local.
Shelly (prises, relais, variateurs, générations 1 et 2+)
rien
oui (mDNS)
Yeelight (Xiaomi)
activer « Contrôle LAN » une fois dans l'appli Yeelight
oui
Home Assistant
un jeton d'accès, et c'est toutes ses marques d'un coup : IKEA, Sonoff, Tuya, Nanoleaf, Zigbee…
oui (mDNS)
📌Ta marque n'est pas dans la liste ?
Si elle marche avec Home Assistant (presque toutes), branche Home Assistant : capy.domo pilote alors ses lampes et ses prises comme les autres. Et tu peux aussi ajouter une marque toi-même.
Sur le wifi de la maison, cherche les appareils : capy domo scan.
Pour Hue : capy domo pair hue, puis appuie sur le bouton du pont quand on te le demande.
Regarde ce qui a été trouvé : capy domo list.
Pilote : capy domo salon on 30% orange, ou depuis Python.
terminal
capy domo scan
résultat
Recherche sur le réseau…
+ wiz-1234 wiz 192.168.1.31
+ yeelight-0123 yeelight 192.168.1.50
+ shellyplus1-abcd shelly 192.168.1.40
! Pont Hue trouvé à 192.168.1.10 : appaire-le avec capy domo pair hue 192.168.1.10
3 appareil(s) ajouté(s) dans /Users/izac/maison/capy.domo.json (renomme-les dans ce fichier si tu veux)
Tout est rangé dans **capy.domo.json**, dans le dossier où tu lances la commande. Ouvre-le pour donner de vrais noms à tes appareils ("wiz-1234" → "bureau") : c'est ce nom qu'on utilise partout ensuite.
⚠️Seulement chez toi
Le scan trouve tout ce qui répond sur le wifi où tu es. Sur un wifi public ou chez quelqu'un d'autre, ne lance pas de scan : ce ne sont pas tes lampes.
brightness(), color() et white() allument la lampe si elle était éteinte : pas besoin d'appeler on() avant.
💡Les couleurs
Les noms en français et en anglais marchent (rouge, orange, jaune, vert, cyan, bleu, violet, rose, blanc…). Si simplesuit est installé, ses 246 noms marchent aussi ("tomato", "mochapeach"…).
🪤Une prise n'a pas de couleur
prise.brightness(50) lève ValueError: 'prise' est une prise / un interrupteur : seulement on, off et toggle. Pareil pour une lampe sans couleur à qui on demande color() : le message propose white() ou brightness().
Un groupe se pilote exactement comme un appareil, et tous ses appareils changent en même temps (pas l'un après l'autre). home["tout"] est un groupe spécial avec tous les appareils.
python
home.set_group("salon", ["lampe-du-salon", "bureau", "prise-tv"])
home.save()
home["salon"].color("orange") # la prise s'allume juste, les lampes passent en orange
home["tout"].off() # tout éteindre avant de partirprint(home["salon"].state()) # {nom: état} pour chaque appareil
📌Chacun fait ce qu'il peut
Dans un groupe, une prise ignore la couleur et la luminosité (elle s'allume), une lampe sans couleur prend un blanc neutre. Si un appareil ne répond pas, les autres sont quand même réglés, puis une erreur dit lequel.
home.scene("film")
home.save_scene("soiree") # Enregistre l'état actuel de tout comme une scène
home.save_scene("bureau", ["bureau"]) # … ou seulement de certains appareils
home.save()
💡Faire une scène sans écrire de JSON
Règle tes lampes comme tu veux (à la main, ou avec l'appli de la marque), puis home.save_scene("nom") et home.save() : la scène est copiée de ce que tu vois.
Tu peux renommer un appareil en changeant sa clé ("wiz-1234" → "bureau"), et ses groupes avec.
capy.domo.Home() cherche le fichier à côté du script lancé, puis dans les dossiers au-dessus. Pour un autre : capy.domo.Home("~/maison/capy.domo.json").
🔒Le username Hue et le jeton Home Assistant sont des mots de passe
Le fichier les contient : ne le mets pas sur GitHub. Pour Home Assistant, garde plutôt le jeton dans une variable d'environnement (export HA_TOKEN='…' dans ~/.zshrc) : pair homeassistant écrit alors seulement "$HA_TOKEN" dans le fichier. Ça marche pour toutes les valeurs : un texte qui commence par $ est lu dans les variables d'environnement.
Hue : le pont n'obéit qu'aux programmes qu'on a autorisés en appuyant sur son bouton.
terminal
capy domo pair hue
résultat
Appuie sur le gros bouton rond du pont Hue (192.168.1.10), puis sur Entrée…
+ lampe-du-salon hue
+ lampe-chambre hue
+ prise hue
3 appareil(s) ajouté(s) dans /Users/izac/maison/capy.domo.json
Home Assistant : dans Home Assistant, ouvre ton profil → onglet Sécurité → Créer un jeton (jeton d'accès longue durée), puis :
Le jeton est demandé sans s'afficher (ou lu dans HA_TOKEN s'il existe). Toutes les lampes (light.…) et prises / interrupteurs (switch.…) de Home Assistant sont ajoutées.
Une maison peut être pilotée depuis un autre ordinateur, le téléphone d'un ami… ou depuis n'importe où avec capy.network. Sur l'ordi qui reste à la maison (un Raspberry Pi, par exemple) :
terminal
capy domo serve
Il utilise le capy.config.json du dossier s'il y en a un (local, wifi ou network), sinon un serveur capy sur le port 4040. Puis, de l'autre côté, avec le même capy.config.json :
python
import capy.domo
maison = capy.domo.Remote() # lit capy.config.json
maison["salon"].color("orange") # mêmes méthodes qu'à la maison
maison.scene("film")
print(maison.list()) # appareils, groupes, scènes
Ou directement depuis le terminal : capy domo --remote salon off.
💡Allumer la maison depuis le bout du monde
Mets {"name": "maison", "relay": "mon-vps.fr:3879", "key": "$CAPY_KEY"} dans le capy.config.json des deux côtés : la maison se branche au relais, et ton client l'appelle par son nom, même derrière la box.
🔒Qui peut appeler ta maison ?
Tous ceux qui peuvent joindre le serveur : tout le wifi en mode local ou wifi, et ceux qui ont la clé du relais en mode network. Ne partage pas cette clé.
Chaque marque parle sa propre langue, et capy.domo traduit :
Marque
Comment on la trouve
Comment on lui parle
Hue
SSDP + mDNS (_hue._tcp)
HTTP vers le pont (API v1)
WiZ
message UDP à tout le wifi, port 38899
UDP, petits messages JSON
Shelly
mDNS (_shelly._tcp, _http._tcp)
HTTP (/rpc/… en gén. 2+, /relay/… en gén. 1)
Yeelight
SSDP sur le port 1982
TCP, une ligne JSON par commande, port 55443
Home Assistant
mDNS (_home-assistant._tcp)
son API REST, avec le jeton
Le scan interroge toutes les marques en même temps, en 2 secondes environ. Chaque commande attend la réponse de l'appareil : state() renvoie "reachable": False si l'appareil est injoignable, et les autres méthodes lèvent ConnectionError: capy.domo : 'bureau' (wiz) ne répond pas : ….
Une marque, c'est une classe qui hérite de capy.domo.Device et écrit deux méthodes : _apply(changes) reçoit {"on", "brightness" (en %), "rgb" (r, g, b), "kelvin"} (seulement ce qui change), _state() renvoie l'état. Tout le reste (vérifications, couleurs, groupes, scènes, à distance, ligne de commande) vient tout seul.
ma_marque.py
import capy.domo
classMaLampe(capy.domo.Device):
brand = "ma-marque"def_apply(self, changes):
... # envoyer changes["on"], changes.get("brightness")… à l'appareil (self.host)def_state(self):
return {"on": True, "brightness": 50}
capy.domo.BRANDS["ma-marque"] = MaLampe # pour que capy.domo.json la reconnaisse
home = capy.domo.Home()
home.add(MaLampe("entree", "192.168.1.60"))
home.save()
Le contrôle par le réseau est coupé par défaut : appli Yeelight → l'ampoule → réglages → Contrôle LAN.
📌WiZ ne descend pas sous 10 %
Les ampoules WiZ ont un minimum de 10 % : brightness(5) donne 10 %. brightness(0) éteint.
🪤L'appareil a changé d'IP
Ta box peut donner une autre adresse à un appareil après une coupure. Relance capy domo scan, ou mieux : donne-lui une IP fixe dans les réglages de la box (« bail DHCP statique »).
💡Tester sans rien casser
state() ne change rien : parfait pour vérifier qu'un appareil répond. Et home["tout"].state() fait le tour de la maison.
Et Google Home, Alexa, Apple Maison ?
Ce sont des applis dans le cloud, pas des appareils : capy.domo parle directement aux appareils, sur ton réseau. Les lampes que tu pilotes avec Alexa sont souvent des Hue, WiZ ou Yeelight : capy.domo les pilote directement. Pour le reste, passe par Home Assistant.
Pourquoi l'API v1 de Hue ?
Elle marche sur tous les ponts, en HTTP simple, et suffit pour allumer, éteindre, régler la luminosité, la couleur et le blanc. capy.domo essaie d'abord en https, puis en http.
Exemples
capy.domo : trouver les appareils de la maison
Cherche sur le wifi les lampes et prises Hue, WiZ, Shelly, Yeelight et Home Assistant, en même temps. ⚠️ À lancer chez toi : sur un wifi public, ce ne sont pas tes lampes.
trouver_les_appareils.py
import capy.domo
home = capy.domo.Home() # capy.domo.json à côté du script (créé au save())
nouveaux = home.scan() # ~2 secondesfor appareil in nouveaux:
print(f"+ {appareil.name:<18} {appareil.brand:<14} {appareil.host or appareil.id}")
for conseil in home.hints: # Ex : « Pont Hue trouvé à 192.168.1.10 : appaire-le avec … »print("!", conseil)
home.save()
print(home) # <capy.domo.Home 4 appareils, 0 groupes, 0 scènes (…/capy.domo.json)>
capy.domo : piloter une lampe
Les mêmes méthodes pour toutes les marques. Régler une lampe éteinte l'allume.
piloter_une_lampe.py
import time
import capy.domo
home = capy.domo.Home()
lampe = home["bureau"] # Le nom donné dans capy.domo.json
lampe.on()
lampe.brightness(30) # %
time.sleep(1)
lampe.color("orange") # "orange", "#FF7800" ou (255, 120, 0)
time.sleep(1)
lampe.white("chaud") # Ou 2700 (kelvins)
time.sleep(1)
lampe.set(brightness=100, color="bleu")
print(lampe.state()) # {'on': True, 'brightness': 100, 'color': '#0000FF', 'kelvin': None, 'reachable': True}
lampe.off()
capy.domo : groupes et scènes
Un groupe se pilote comme une lampe (tous ensemble, en même temps). Une scène règle plusieurs groupes d'un coup. Groupes et scènes sont gardés dans capy.domo.json.
groupes_et_scenes.py
import capy.domo
home = capy.domo.Home()
home.set_group("salon", ["lampe-du-salon", "bureau"])
home.scenes["film"] = {
"salon": {"brightness": 15, "color": "orange"},
"cuisine": {"on": False},
}
home.save()
home["salon"].white("chaud") # Les deux lampes en même temps
home.scene("film") # Tout le monde prend son réglage
home["tout"].off() # « tout » : tous les appareils
home.save_scene("soiree") # L'état actuel devient une scène
home.save()
capy.domo à distance : la maison devient un serveur capy
Sur l'ordi qui reste à la maison. Avec un capy.config.json, c'est son mode (local, wifi ou network) qui est utilisé : avec "relay", la maison est pilotable depuis tout internet. Sans, un serveur capy sur le port 4040.
Ailleurs : la télécommande, avec le même capy.config.json
telecommande.py
import capy.domo
maison = capy.domo.Remote() # Lit capy.config.jsonprint(maison.list()["devices"]) # Ce qu'il y a à la maison
maison["salon"].color("orange") # Mêmes méthodes qu'en local
maison.scene("film")
maison["tout"].off()
capy.domo : un réveil lumineux
La lampe s'allume très doucement, d'un orange chaud vers un blanc de jour, comme un lever de soleil. Lance-le avec l'heure du réveil : python3 19_reveil_lumineux.py 7:30
reveil_lumineux.py
import datetime as dt
import sys
import time
import capy.domo
DUREE = 20 * 60# Le lever de soleil dure 20 minutes
lampe = capy.domo.Home()["chambre"]
heure, minute = (int(x) for x in (sys.argv[1] iflen(sys.argv) > 1else"7:30").split(":"))
reveil = dt.datetime.now().replace(hour=heure, minute=minute, second=0, microsecond=0)
if reveil < dt.datetime.now():
reveil += dt.timedelta(days=1)
debut = reveil - dt.timedelta(seconds=DUREE)
print(f"Lever de soleil de {debut:%H:%M} à {reveil:%H:%M} (Ctrl+C pour annuler)")
time.sleep(max((debut - dt.datetime.now()).total_seconds(), 0))
for etape inrange(1, 61): # 60 étapes : toutes les 20 secondes
avance = etape / 60if avance < 0.4:
lampe.set(brightness=1 + avance * 50, color=(255, round(80 + avance * 200), 0)) # Rouge-orangé qui monteelse:
lampe.set(brightness=avance * 100, kelvin=round(2200 + avance * 3800)) # Puis blanc de plus en plus froid
time.sleep(DUREE / 60)
print("Debout ! ☀️")
Ce qu'on garde dans capy.domo.json pour le retrouver
Voir le code
python
defconfig(self):
data = {"brand": self.brand, "host": self.host, "id": self.id, "kind": self.kind}
if self.kind == "light":
data.update(color=self.has_color, white=self.has_white)
data.update(self.extra)
return {k: v for k, v in data.items() if v isnotNone}
defset(self, on=None, brightness=None, color=None, kelvin=None):
changes = {}
if brightness isnotNone:
changes["brightness"] = to_percent(brightness)
if changes["brightness"] == 0: # 0 % = éteint
changes.pop("brightness")
on = Falseif color isnotNone:
changes["rgb"] = to_rgb(color)
if kelvin isnotNone:
changes["kelvin"] = to_kelvin(kelvin)
if self.kind == "switch"and changes:
raiseValueError(f"capy.domo : {self.name!r} est une prise / un interrupteur : seulement on, off et toggle")
if"rgb"in changes andnot self.has_color:
raiseValueError(f"capy.domo : {self.name!r} ne fait pas de couleurs (essaie white() ou brightness())")
if"kelvin"in changes andnot self.has_white:
raiseValueError(f"capy.domo : {self.name!r} ne règle pas la température du blanc")
if"rgb"in changes and"kelvin"in changes:
raiseValueError("capy.domo : une couleur OU un blanc, pas les deux à la fois")
if changes and on isNone:
on = True# Régler la lumière d'une lampe éteinte l'allumeif on isnotNone:
changes["on"] = bool(on)
if changes:
try:
self._apply(changes)
exceptConnectionErroras e:
raiseConnectionError(f"capy.domo : {self.name!r} ({self.brand}) ne répond pas : {e}") fromNonereturn self
defgroup(self, name):
if name in EVERYTHING:
returnGroup(name, self.devices.values())
if name notin self.groups:
raiseKeyError(f"capy.domo : aucun groupe {name!r} (groupes : {', '.join(self.groups) or 'aucun'})")
missing = [n for n in self.groups[name] if n notin self.devices]
if missing:
raiseKeyError(f"capy.domo : le groupe {name!r} contient {', '.join(missing)}, qui n'existe(nt) pas")
returnGroup(name, [self.devices[n] for n in self.groups[name]])
Ajouter un appareil (nom choisi tout seul s'il est pris) -> son nom
Voir le code
python
defadd(self, device, name=None):
base = slug(name or device.name)
name, n = base, 2while name in self.devices or name in self.groups or name in EVERYTHING:
name, n = f"{base}-{n}", n + 1
device.name = name
self.devices[name] = device
return name
Créer (ou remplacer) un groupe. Ex : home.set_group("rdc", ["salon", "cuisine"])
Voir le code
python
defset_group(self, name, members):
unknown = [m for m in members if m notin self.devices]
if unknown:
raiseKeyError(f"capy.domo : appareil(s) inconnu(s) : {', '.join(unknown)}")
self.groups[slug(name)] = list(members)
defscene(self, name):
if name notin self.scenes:
raiseKeyError(f"capy.domo : aucune scène {name!r} (scènes : {', '.join(self.scenes) or 'aucune'})")
settings = {target: {k: v for k, v in values.items() if k in ("on", "brightness", "color", "kelvin")}
for target, values in self.scenes[name].items()}
Group(name, [self[target] for target in settings])._run(lambda target: target.set(**settings[target.name]))
return self
Enregistrer l'état actuel comme une scène. Ex : home.save_scene("soiree", ["salon", "cuisine"])
Voir le code
python
defsave_scene(self, name, targets=None):
devices = [self.devices[t] for t in targets] if targets elselist(self.devices.values())
scene = {}
for device_name, state inGroup(name, devices).state().items():
ifnot state["reachable"]:
continue
settings = {"on": state["on"]}
if state["on"]:
settings.update({k: state[k] for k in ("brightness", "color", "kelvin") if state[k] isnotNone})
if"color"in settings and"kelvin"in settings:
settings.pop("color")
scene[device_name] = settings
self.scenes[slug(name)] = scene
return scene
Chercher les appareils du réseau -> [les nouveaux appareils] (pas encore enregistrés : save() pour les garder)
Les ponts Hue et Home Assistant pas encore appairés sont listés dans home.hints
addresses : pour viser une autre adresse que tout le réseau (tests), ex : {"wiz": ("127.0.0.1", 38899)}
Voir le code
python
defscan(self, timeout=2.0, brands=None, addresses=None, add=True):
brands, addresses = brands orlist(BRANDS), addresses or {}
found, self.hints = [], []
jobs = {
"wiz": lambda: _wiz.discover(timeout, **_address(addresses, "wiz")),
"yeelight": lambda: _yeelight.discover(timeout, **_address(addresses, "yeelight")),
"shelly": lambda: _shelly.discover(timeout, **_address(addresses, "shelly")),
"hue": lambda: self._scan_hue(timeout, addresses),
"homeassistant": lambda: self._scan_homeassistant(timeout, addresses),
}
results = {}
defrun(brand):
try:
results[brand] = jobs[brand]()
except (ConnectionError, OSError) as e:
self.hints.append(f"{brand} : {e}")
threads = [threading.Thread(target=run, args=(b,)) for b in brands if b in jobs]
for t in threads:
t.start()
for t in threads:
t.join()
known = {(d.brand, d.host, d.id) for d in self.devices.values()}
for brand in brands:
for device in results.get(brand, []):
if (device.brand, device.host, device.id) notin known:
known.add((device.brand, device.host, device.id))
if add:
self.add(device)
found.append(device)
return found
Un blanc -> kelvins. Accepte 2700, "2700K", "chaud", "neutre", "froid"
Voir le code
python
defto_kelvin(value):
text = str(value).strip().lower()
if text in WHITES:
return WHITES[text]
match = re.fullmatch(r"(\d+)\s*k?", text)
ifnot match:
raiseValueError(f"capy.domo : blanc inconnu {value!r} (un nombre de kelvins comme 2700, ou chaud / neutre / froid)")
kelvin = int(match.group(1))
ifnot1000 <= kelvin <= 10000:
raiseValueError(f"capy.domo : {kelvin} K n'est pas un blanc (de 2000 K, très chaud, à 6500 K, très froid)")
return kelvin
defto_rgb(color):
ifisinstance(color, (tuple, list)) andlen(color) == 3:
r, g, b = (int(c) for c in color)
ifall(0 <= c <= 255for c in (r, g, b)):
return r, g, b
raiseValueError(f"capy.domo : chaque valeur RGB va de 0 à 255, reçu {tuple(color)}")
text = str(color).strip().lower().replace(" ", "")
if text in COLORS:
return COLORS[text]
match = re.fullmatch(r"rgb\((\d+),(\d+),(\d+)\)", text)
if match:
returnto_rgb([int(x) for x in match.groups()])
hexa = text.lstrip("#")
if re.fullmatch(r"[0-9a-f]{3}", hexa):
hexa = "".join(c * 2for c in hexa)
if re.fullmatch(r"[0-9a-f]{6}", hexa):
returnint(hexa[0:2], 16), int(hexa[2:4], 16), int(hexa[4:6], 16)
try: # Les 246 noms de simplesuit.colors, s'il est installéfrom simplesuit import colors
return colors.to_rgb(text)
except (ImportError, ValueError):
pass
close = difflib.get_close_matches(text, list(COLORS) + list(WHITES), n=3)
hint = f" (tu voulais dire : {', '.join(close)} ?)"if close else" (un nom, \"#FF7800\" ou (255, 120, 0))"raiseValueError(f"capy.domo : couleur inconnue {color!r}{hint}")
capy.domo.json à côté du script, ou dans un dossier au-dessus (comme git)
Voir le code
python
deffind(start=None):
folder = pth.Path(start or_main_dir()).resolve()
returnnext((place / FILE_NAME for place in (folder, *folder.parents) if (place / FILE_NAME).is_file()), None)