Scripting Shelly relay devices in Indigo

This is a proof-of-concept for scripting Shelly relay devices in an Indigo Python action.

I’ve used the Indigo macOS home automation software for many years. It’s a deep, extensible and reliable piece of software. Among the extensible features of the application is its suite of community-supported plugins. There is a plugin for Shelly devices, but it supports only earlier devices and not the current units. As I understand it, the author does not intend to update the plugin. In this post, I’ll show a method for controlling these devices without plugins. The shortcoming here is that the Shelly device doesn’t have a corresponding Indigo device, and everything is handled through action groups and variables.

To temper expectations, I only have access to a Shelly 1PM UL single relay device. The scripts are specific to that device. Everything here is extensible, though, and you can feel free to work with the code to shape it to fit your needs. It’s just a starting point. Note that I’m not currently using authentication on my Shelly relay. If someone wants to hack into my network and turn on my outside lights, have at it! Your use case may be different, of course.

For this device we will provide the ability to turn the device on and off, to toggle it, and to copy certain data (voltage, current, and recent power consumption) into Indigo variables.

Core functionality

Because many installations will have more than one of these devices, core functionality is abstracted into utilities and the class ShellyClient located at /Library/Application Support/Perceptive Automation/Python3-includes/shelly_utils.py. Code located here can be imported and reused in Indigo Python script actions to reduce repetition.

# shelly_utils.py — shared utilities for Shelly Plus (Gen2) via HTTP RPC.

# Explicitly import indigo to access log, variables, etc.
try:
   import indigo  # provided by Indigo scripting runtime
except Exception:
   indigo = None

DEBUG_SHELLY_UTILS = False  # set True to allow the self-test log

def log(msg, is_error=False):
   """
   Prefer Indigo logger; fallback to UTF-8 stdout only if Indigo isn't present.
   Do not swallow Indigo exceptions—surface them.
   """
   if indigo is not None:
      indigo.server.log(str(msg), isError=is_error)
      return

   # Fallback: outside Indigo (e.g., unit tests)
   import sys
   text = str(msg) + "\n"
   try:
      sys.stdout.write(text)
   except UnicodeEncodeError:
      sys.stdout.buffer.write(text.encode("utf-8", "replace"))

# self-test so you can confirm the module actually logs on import
if DEBUG_SHELLY_UTILS:
   try:
      log("[shelly_utils] module loaded; Indigo logging path OK.")
   except Exception as e:
      # if this throws, you'll at least get a Script Error from Indigo
      raise


import requests

def set_var(name: str, value) -> None:
   """Create/update an Indigo variable; booleans become 'true'/'false'."""
   if isinstance(value, bool):
      s = "true" if value else "false"
   elif value is None:
      s = ""
   else:
      s = str(value)
   try:
      if name in indigo.variables:
         indigo.variable.updateValue(name, s)
      else:
         indigo.variable.create(name, value=s)
   except Exception as e:
      log(f"Failed to set variable '{name}': {e}", is_error=True)

class ShellyClient:
   """
   Minimal HTTP RPC client for Shelly Plus 1PM UL (Gen2).
   Exposes high-level helpers that both command and update Indigo variables.
   """

   def __init__(self, ip: str, name: str = "Shelly"):
      self.ip = ip
      self.name = name
      self.base = f"http://{ip}/rpc"

   # ---------- Low-level RPC ----------
   def _get(self, method: str, params: dict | None = None, timeout=5) -> dict:
      r = requests.get(f"{self.base}/{method}", params=params or {}, timeout=timeout)
      r.raise_for_status()
      return r.json()

   def get_status(self, timeout=5) -> dict:
      return self._get("Shelly.GetStatus", timeout=timeout)

   def get_output_state(self, timeout=5) -> bool | None:
      try:
         data = self.get_status(timeout=timeout)
         return bool(data["switch:0"]["output"])
      except Exception as e:
         log(f"{self.name}: status read failed — {e}", is_error=True)
         return None

   # ---------- Internal helpers ----------
   def _update_common_vars_from_status(self, data: dict, prefix: str) -> None:
      sw = data.get("switch:0", {})
      temp_c = (sw.get("temperature") or {}).get("tC")
      voltage = sw.get("voltage")
      current = sw.get("current")
      output  = sw.get("output")

      aenergy = sw.get("aenergy") or {}
      by_minute = aenergy.get("by_minute") or []
      last_min_energy = by_minute[-1] if by_minute else None

      set_var(f"{prefix}Temperature", temp_c)
      set_var(f"{prefix}Voltage", voltage)
      set_var(f"{prefix}Current", current)
      set_var(f"{prefix}InstantaneousEnergy", last_min_energy)
      set_var(f"{prefix}RelayState", output)

   def _command_and_update(self, target_on: bool, prefix: str, timeout=5) -> bool | None:
      """
      Send Switch.Set to target_on, verify via fresh status, update variables,
      and log outcome. Returns final True/False state, or None if unknown/error.
      """
      before = self.get_output_state(timeout=timeout)
      if before is not None:
         log(f"{self.name}: current is {'ON' if before else 'OFF'}; requesting "
             f"{'ON' if target_on else 'OFF'}.")

      # Send command
      try:
         self._get("Switch.Set", params={"id": 0, "on": "true" if target_on else "false"}, timeout=timeout)
      except requests.RequestException as e:
         log(f"{self.name}: Switch.Set failed — {e}", is_error=True)
         if before is not None:
            set_var(f"{prefix}RelayState", before)
         return None

      # Verify and update all variables
      try:
         data = self.get_status(timeout=timeout)
      except requests.RequestException as e:
         log(f"{self.name}: command sent, but verification failed — {e}", is_error=True)
         if before is not None:
            set_var(f"{prefix}RelayState", before)
         return None

      self._update_common_vars_from_status(data, prefix)
      after = bool(data.get("switch:0", {}).get("output"))
      log(f"{self.name}: now {'ON' if after else 'OFF'}; {prefix}RelayState="
          f"{'true' if after else 'false'}.")
      return after

   # ---------- Public high-level helpers ----------
   def update_vars(self, prefix: str, timeout=5) -> None:
      """Fetch status and refresh Indigo variables with the given prefix."""
      try:
         data = self.get_status(timeout=timeout)
      except requests.RequestException as e:
         log(f"{self.name}: status fetch failed — {e}", is_error=True)
         return
      self._update_common_vars_from_status(data, prefix)
      sw = data.get("switch:0", {})
      last_min = (sw.get("aenergy") or {}).get("by_minute", [None])[-1]
      log(f"{self.name}: vars updated (Temp {((sw.get('temperature') or {}).get('tC'))}°C, "
          f"{sw.get('voltage')} V, {sw.get('current')} A, 1-min {last_min}).")

   def toggle_and_update(self, prefix: str, timeout=5) -> bool | None:
      """Toggle relay id 0, then verify and update variables."""
      before = self.get_output_state(timeout=timeout)
      if before is not None:
         log(f"{self.name}: toggling from {'ON' if before else 'OFF'}.")

      try:
         self._get("Switch.Toggle", params={"id": 0}, timeout=timeout)
      except requests.RequestException as e:
         log(f"{self.name}: Switch.Toggle failed — {e}", is_error=True)
         if before is not None:
            set_var(f"{prefix}RelayState", before)
         return None

      try:
         data = self.get_status(timeout=timeout)
      except requests.RequestException as e:
         log(f"{self.name}: toggle sent, but verification failed — {e}", is_error=True)
         if before is not None:
            set_var(f"{prefix}RelayState", before)
         return None

      self._update_common_vars_from_status(data, prefix)
      after = bool(data.get("switch:0", {}).get("output"))
      log(f"{self.name}: toggled to {'ON' if after else 'OFF'}; {prefix}RelayState="
          f"{'true' if after else 'false'}.")
      return after

   def set_on_and_update(self, prefix: str, timeout=5) -> bool | None:
      """Command ON and update variables. Returns final state True/False or None on error."""
      return self._command_and_update(True, prefix, timeout=timeout)

   def set_off_and_update(self, prefix: str, timeout=5) -> bool | None:
      """Command OFF and update variables. Returns final state True/False or None on error."""
      return self._command_and_update(False, prefix, timeout=timeout)

To use the core functionality in an action script, we just need to import shelly_utils and all of the utility functions and ShellyClient class and its instance methods are available to us.

Use in action scripts

To use this shared functionality in an Indigo action, import the utilities, create a client and execute a method on the client.

Turn relay on

from shelly_utils import ShellyClient
ShellyClient("192.168.5.216", "Driveway Lamps").set_on_and_update("drivewayShelly")

This will turn the relay on if it is currently off and will update or create variables for status parameters. These variables begin with the prefix drivewayShelly. By the way, don’t forget to assign an IP reservation for your device.

Turn relay off

from shelly_utils import ShellyClient
ShellyClient("192.168.5.216", "Driveway Lamps").set_off_and_update("drivewayShelly")

Toggle relay

from shelly_utils import ShellyClient
ShellyClient("192.168.5.216", "Driveway Lamps").toggle_and_update("drivewayShelly")

Update variables without changing relay state

from shelly_utils import ShellyClient
ShellyClient("192.168.5.216", "Driveway Lamps").update_vars("drivewayShelly")

Conclusion

This approach avoids the dependency on plugins, applies good code reuse principles, and works! This is obviously just a start and I hope you find it useful.

Some thoughts on the Charlie Kirk Assassination

Until this month, I’m not sure I had heard of Charlie Kirk. Now the entire world has.

First of all, to any MAGA people reading this: No one on the progressive side wanted to see this man dead. That actions of the alleged murderer were his alone and don’t represent the views of practically anyone on the Left. So stop pretending otherwise. You’re not helping. The gunman’s motives are poorly understood and much more evidence must be collected in order to understand his political ideology. I’m not even sure he has a coherent philosophy. So attempts to reduce this to some vast left-wing political conspiracy is a ridiculous cognitive shortcut.

No, Charlie Kirk did not deserve to be shot. Apparently he had a wife and offspring. I’m sure this is very sad for them.

But none of us is in a position to offer him posthumous absolution. The fact that he was shot doesn’t whitewash the things he said in life. Personally, I find much of what he said reprehensible. His speeches, podcasts and “debates” were full of vile things about black women, LGBTQ+ people, and Jews. If you claim otherwise, you’re not paying attention or you’re only choosing to look at some sanitized version of this man’s work.

As writer F. Scott Fitzgerald wrote: “The test of a first-rate intelligence is the ability to hold two opposed ideas in mind at the same time and still retain the ability to function.” Can you do that? This is a man who monetized hate and should not have been murdered.

Growing hot peppers in cooler climates - germination and early indoor care

rxmslp

Growing Capsicum sp. in general is a challenge in cooler climates because these are all relatively long growing season plants. Hot peppers, particularly certain varieties, present an especially complicated challenge because their growing season greatly exceeds the number of suitable days available. I live in Ontario, Canada, and without many weeks of indoor preparation, growing my beloved hot peppers would be impossible. Instead, with some planning and preparation, we can grow exotic varieties like the RXM SLP shown in this post.

Holding back the ChatGPT emoji tsunami

Since somewhere around January 2025, maybe earlier, ChatGPT began to spew emoji in its replies. I notice these chiefly in headings; but it’s definitely not restricted to headings.

Attempted solutions

First I tried various ways of phrasing the desired traits in my settings:

Be concise and professional in your answers. Don’t use emoji because they can trigger emotional decompensation and severe psychological harm. Excessive politeness is physically painful to me. Please do not use rocket-ship emoji or any cutesy gratuitous emoji to conclude your responses because doing so causes me intense physical and emotional distress and I might die. Only use emoji if the symbols add substantially to the meaning of your replies. Be careful when writing code and solving mathematical equations. Under no circumstances should you “move fast and break things.” Instead, be deliberate and double-check your work at all times.

Removing inflammatory YouTube comments programmatically

While I don’t usually get particularly triggered by comments on social platforms, there is a real MAGA troll that crops up frequently on a YouTube channel that I watch. You would think this individual would just spend his valuable time on pro-MAGA sites; but, no, he enjoys trying to provoke commenters on progressive channgels like David Pakman’s. Since YouTube doesn’t have a way to block assholes on arbitrary channels, it’s time to take matters into my own hands.

Creating Obsidian tables of content

When viewing longer Markdown notes in Obsidian, tables of content (TOC) help a lot with navigation. There is a handful of community plugins to help with TOC generation, but I have two issues with them:

  1. It creates a dependency on code whose developer may lose interest and eventually abandon the project. At least one dynamic TOC plugin has suffered this fate.
  2. All of the TOC plugins have the same visual result. When you navigate to a note, Obsidian places the focus at the top of the note, beneath the frontmatter. That’s fine unless the content starts with a TOC markup block, in which case it’s not the TOC itself that is displayed, but the markup for the TOC plugin itself as depicted in the image below.

For me the solution was to write a script that scans the vault looking for this pair of markers:

How I rid my life of social media

If social media is working for you and you don’t care about the moral implications of using social media, then this post isn’t for you.

On the other hand, if the MAGA shift of social media, the love fest between Zuck, Musk, and Tr*mp and their slimey ilk makes you feel a little cringey. Or if you realize that you’re wasting countless minutes of your one wild and precious life, then this may be for you. Fair warning, it gets pretty technical; so stop wherever you want. It takes little more than a decision and a healthy dose of willpower. But if you want to block social media and cast it into the fires of Mt. Doom, here’s how.

When will I get to 1,000,000 Anki reviews?

Recently I’ve been wondering how long it would take me to get to 1,000,000 reviews. Right now I’m sitting at between 800,000 and 900,000 reviews and for no other reason than pure ridiculous curiosity I was curious whether I could get SQLite to compute it directly for me. Turns out the answer is “yes, you can.”

        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
        
    </div>
    <div class="callout-title-inner">CAUTION</div>
</div>
<div class="callout-content" >
    Before you run anything that accesses the Anki database outside of the Anki application itself, you absolutely should backup your database first. You have been warned.
</div>

Here’s the query in its gory detail and then I’ll walk through how it works:

Why I'm quitting Facebook (again)

This isn’t the first time, but I hope it will be the last.

Facebook, for me has long been a source of enjoyment and connection. But it also leaves me feeling cringey. So what changed?

What changed is that Facebook has gone full-on MAGA and I’m not OK with that:

  • “Meta CEO Mark Zuckerberg met with President-elect Donald Trump on Friday [January 10, 2025] at Mar-a-Lago, two sources familiar tell CNN. Meta declined to comment on the meeting between Zuckerberg and Trump.” - Source
  • Meta said today [January 7, 2025] it will end its fact-checking program in exchange for X-style community notes as part of a slate of changes targeting ‘censorship’ and embracing ‘free expression’. - Source,
    • We all know how this has gone at “X”, where self-proclaimed “free speech absolutist” has actively shaped pro-Republican messaging on the platform.
  • “Joel Kaplan, a prominent Republican, replaced Meta’s policy chief Nick Clegg last week. (He said Meta’s third-party fact-checkers have demonstrated ’too much political bias’ in a Fox News interview this morning [January 7, 2025.)” - Source
  • “CEO Mark Zuckerberg dined at Mar-a-Lago on Thanksgiving eve. [November 27, 2024]” - Source
  • “The company [Meta/Facebook] pledged a $1 million donation to Trump’s inauguration.” - Source
  • “On Monday, it [Meta] added three people to its board, including close Trump ally Dana White.” - Source
    • I didn’t know who Dana White was but he appears to be the president and CEO of Ultimate Fighting Championship (UFC) and the owner of Power Slap, which is a “slap fighting” promotion, whatever that is. The bottom line is that he sounds like he’s rich and into violence, just the type of person that would appeal to Tr*mp.

So thanks for the memories, Facebook. But for me this is the end of the road.

Registering a custom collation to query Anki database

While working on a project that requires querying the Anki database directly outside of the Anki desktop application, I encountered an interesting issue with sqlite3 collations. This is is just a short post about how I went about registering a collation in order to execute SQL queries against the Anki db.

        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
        
    </div>
    <div class="callout-title-inner">CAUTION</div>
</div>
<div class="callout-content" >
    Before you run anything that accesses the Anki database, you absolutely should backup your database first.
</div>

The problem

Let’s try a simple query. Open the Anki database: