Pyscript - new integration for easy and powerful Python scripting

Brian,

Since the state variable name (group.dining_room_lights) you want is the value of a variable (light_group), you have to build the string name with the attribute appended, and use state.get:

state.get(f"{light_group}.entity_id")

The argument will be a string that evaluates to "group.dining_room_lights.entity_id", and then state.get() will return the value of that attribute.

Another way to write that which looks cleaner is:

state.get(light_group + ".entity_id")

Craig

2 Likes

Thank you @craigb.

I am working on creating a function which can sequence through a group of colored smart bulbs (similar to what the Hue app does). (For reference my dining room chandelier has 9 hue bulbs.)

@service
def light_sequence(light_group=None, delay=1, transition=5, colors=["red","purple"], brightness=255):
  """Light sequencer to animate the colored bulbs."""
  #log.info(f"light_sequence({light_group}, {delay}, {transition}, {colors}, {brightness})")

  if light_group is not None:
    # Retrieve the list of entity ids in the light group.
    bulbs = state.get(light_group+".entity_id")

    # Loop through the list of colors.
    for color in colors:
      # Loop through the list of bulbs.
      for bulb in bulbs:
        # Stop when the lights are turned off.
        if state.get(light_group) is "off":
          break

        # Brief delay inbetween bulbs.
        task.sleep(delay)

        # Transition the bulb to the new color.
        light.turn_on(entity_id=bulb, color_name=color, transition=transition, brightness=brightness)

      # Stop when the lights are turned off.
      if state.get(light_group) is "off":
        break

      # Delay for the duration of the color transition.
      task.sleep(transition)

To test it I call pyscript.light_sequence with the services page with the following data:

light_group: group.dining_room_light
brightness: 60
delay: .75
transition: 5
colors:
  - aquamarine
  - blueviolet
  - cadetblue
  - coral

The code works well. My ZHA network doesn’t reliably send the command to every bulb, but that’s a problem for another forum. :slight_smile:

P.S. In case you are curious, here are links to the 2 Home Assistant script files I converted this from:

4 Likes

Brian,

Thanks for posting the code - looks really cool! It’s helpful to compare to the corresponding script files.

One comment is that you should use == instead of is when you are checking for "off". The is operator checks if the operands are the same object, not just that they have equal values. So to check that the light is off you should use

if state.get(light_group) == "off":

Craig

1 Like

Thanks @craigb.

How can I use pyscript to stop the script from running when someone turns the light group off. The break statements inside each loop isn’t working.

Brian,

You should add this call

task.unique("light_sequence_running")

at the top of your light_sequence function. That will ensure that if a second service call starts a new light sequence, then the old one will be terminated.

Next, create a new state-trigger function that triggers if the light is turned off. That function should do one thing: also call task.unique("light_sequence_running"). That will cause any existing light_sequence function to be terminated (because it previously called task.unique()). Perhaps it would be something like:

@state_trigger("group.dining_room_light == 'off'")
def dining_room_maual_off():
    task.unique("light_sequence_running")

However, this will stop any light_sequence that is running, even if it’s for a different light group.

Here’s an improvement that probably is better for your application. Since your light_sequence function can run on any light group, you actually want task.unique to only apply to a service function running on that same light group. So you should make the string argument to task.unique (which can be any string) be based on light_group. You probably want to use something like:

task.unique("light_sequence_running_" + light_group)

and you’ll need that same argument in the “manual off” trigger to make sure it stops only the matching service.

For the manual off state trigger to work across multiple groups, you’ll need to list all the light groups that could turn off in the @state_trigger, and use the var_name argument to figure out which light group was turned off (it’s the name of the state variable that just changed to cause the trigger, which is the light group). For example, for two light groups, you could do:

@state_trigger("group.dining_room_light == 'off' or group.living_room_light == 'off'")
def light_group_manul_off(var_name=None):
    task.unique("light_sequence_running_" + var_name)

Unfortunately you can’t use wild-cards in a state_trigger - it needs an explicit list of state variables to watch.

Craig

2 Likes

As an experiment, I converted your pyscript example to python_script to demonstrate the syntactic differences between the two. During this exercise, I encountered the same issue you did, it failed to break out of the loop when the light group was turned off.

To correct it, I employed the technique described in this StackOverflow post: python - Breaking out of nested loops - Stack Overflow

Here’s the python_script version (confirmed it works):

# python_script
light_group = data.get('light_group', None)
delay = data.get('delay', 1)
transition = data.get('transition', 5)
colors = data.get('colors', ["red","purple"])
brightness = data.get('brightness', 255)

if light_group is not None:
  bulbs = (hass.states.get(light_group)).attributes['entity_id']
  for color in colors:
    for bulb in bulbs:
      if (hass.states.get(light_group)).state == 'off':
        break
      time.sleep(delay)
      hass.services.call('light', 'turn_on', {'entity_id': bulb, 'color_name': color, 'transition': transition, 'brightness': brightness}, False)
    else:
      if (hass.states.get(light_group)).state == 'off':
        break
      time.sleep(transition)
      continue
    break

I applied the same technique to your pyscript in the example shown below. However, I have not tested it because my Hue lights are connected to my production server and I’m not running pyscript on it (yet). Comparing the syntax of the two examples, it’s clear the pyscript version provides simplified access to an entity’s state and attributes.

#pyscript
@service
def light_sequence(light_group=None, delay=1, transition=5, colors=["red","purple"], brightness=255):
  if light_group is not None:
    bulbs = state.get(light_group+".entity_id")
    for color in colors:
      for bulb in bulbs:
        if state.get(light_group) == "off":
          break
        task.sleep(delay)
        light.turn_on(entity_id=bulb, color_name=color, transition=transition, brightness=brightness)
      else:
        if state.get(light_group) == "off":
          break
        task.sleep(transition)
        continue
      break

Let me know if it works or not.

2 Likes

@123 - ah good point - a break statement only terminates the innermost loop. However, @BrianHanifin’s code does the same check in the outer loop too.

Perhaps the issue is the check should be after the delay (immediately before it is turned on), rather than before? Or you could check before and after, so that you don’t waste time on the delay if the light was already off. There’s obviously a race condition between when the check occurs and the turn_on service is called relative to when the manual switch off happens. It’s not possible to eliminate that, but you want it to be as short as possible.

Craig

1 Like

After thinking on it some more, the breaks probably don’t work due to my bigger problem. My light groups turn right back on when even one of the bulbs doesn’t report to ZHA that it turned off when the command was sent.

Thank you @123 I am enjoying learning Python. I will try those soon.

There is an else in the pyscript code that looks out of place. It is lined up with the for bulb in bulbs: statement. Help me out. I’m not sure what is supposed to line up with what.

It’s not out of place.
https://book.pythontips.com/en/latest/for_-_else.html#else-clause

Like I said, I got the idea from the linked StackOverflow post. It’s a method for breaking out of any number of nested for-loops.

Ultimate proof that it’s not out of place is that it simply works; when I turn off the light group the looping ceases.

Having said that, with 9 lights in your group, YMMV but the technique is sound.

1 Like

I finally got around to adding all the “test” scripts that I made to figure out time triggers / state triggers / service calls while I was redoing my thermostat last month (and realized I don’t account for my vacation variable) to the wiki (under examples)

I also added the more complex thermostat scripts so others can use them as examples.

Probably work on converting my light scripts next.

I just noticed that the log.info does not seem to be showing up in my logs any more. I don’t believe that I changed anything since I coded all my other ones (and made extensive use of the log while testing so it was working then).

Hello. Pyscript and Python newbie.

I want to read a local image, overlay text, and write it back to file (more context)

I was going to use PIL’s for this. I think I need to import it? I don’t think I get how/where imports happen.
I had a browse of the doco’s but I’m confused.

Could anyone point me in the right direction.

My working draft code is below (I’m new to writing functions, but I think I should be able to figure out the rest once I’ve got PIL’s imported?).

Thank you!

Edit: Code slightly refined and imports modified based on craigb’s help below…

@service
def drawtext_onimage(image_in=None, text_to_overlay=None):

  import PIL.Image as Image
  import PIL.ImageDraw as ImageDraw
  import PIL.ImageFont as ImageFont

  #Log message
  log.warning(f"drawtext_onimage: got image_in {image_in} text {text_to_overlay}")

  if image_in is not None and text_to_overlay is not None:

    #load and create image object
    image = Image.open(image_in)
    draw = ImageDraw.Draw(image)

    # specify fontfile and font size
    font = ImageFont.truetype("arial")
    # ImageFont.truetype("/config/www/motioneye_files/GiraffeCam1/all_timelapse/arial.ttf", 20)

    text = text_to_overlay

    # drawing text size
    draw.text((5, 5), text, font = font, align ="left")

    image.save("/config/www/motioneye_files/GiraffeCam1/all_timelapse/lastestGiraffe_wBoxes_cropped_timestamp.jpg")

  elif action == "fire" and id is not None:
      event.fire(id, param1=12, pararm2=80)

Error message in log:

2020-10-24 12:29:07 ERROR (MainThread) [custom_components.pyscript.file.drawtext_onimage.drawtext_onimage] Exception in <file.drawtext_onimage.drawtext_onimage> line 5:
        from PIL import Image, ImageDraw, ImageFont
        ^
AttributeError: module 'PIL' has no attribute 'ImageDraw'

https://hacs-pyscript.readthedocs.io/en/stable/reference.html#importing

Thanks. I did read that but found it confusing when it came to practically implementing it. I’ll review again though.

I’m not familiar with PIL, and I’m just checking it out now. It appears to use some sort of dynamic importing of the submodules, which pyscript doesn’t handle correctly. I’m looking into it.

As a quick workaround, this appears to work:

import PIL.Image as Image
import PIL.ImageDraw as ImageDraw
import PIL.ImageFont as ImageFont

Manipulating the image involves reading and writing files (ie, doing I/O), which should not be done in the HASS event loop. It will probably work ok, but you might get some warning messages from HASS, and any blocking from I/O could delay other HASS activities.

So here’s what I’d recommend. Move all the image manipulation code into a true native python module. Your original importing code will work fine in that module. Then the pyscript service can just call the python code in that module, using task.executor to run the code in its own thread. This is described in the documentation.

Here are the steps. Create a new directory config/pyscript_modules. In that directory, create a file image_tools.py that contains this native python code:

from PIL import Image, ImageDraw, ImageFont
 
def draw_text(image_in=None, text_to_overlay=None):
    #load and create image object
    image = Image.open(image_in)
    draw = ImageDraw.Draw(image)

    # specified font
    font = ImageFont.truetype("/config/www/motioneye_files/GiraffeCam1/all_timelapse/arial.ttf", 20)
    text = text_to_overlay

    # drawing text size
    draw.text((5, 5), text, font = font, align ="left")
    image.save("/config/www/motioneye_files/GiraffeCam1/all_timelapse/lastestGiraffe_wBoxes_cropped_timestamp.jpg")

Note: you can’t use pyscript things like log or @service in this file - this is native python code.

Next, your pyscript file in config/pyscripts/image_draw.py should do this:

import sys

if "config/pyscript_module" not in sys.path:
    sys.path.append("config/pyscript_modules")

import image_tools

@service
def drawtext_onimage(image_in=None, text_to_overlay=None):
    log.warning(f"drawtext_onimage: got image_in {image_in} text {text}")
    if image_in is not None and text is not None:
        task.executor(image_tools.draw_text, image_in, text_to_overlay)

This is untested…

1 Like

Much appreciated. I’ll look over this and try to get my head around it. Your earlier post got me a bit further (I edited my code in initial post), but interestingly it errored out with what looked like the same reason I moved away from FFMPEG and on to a Python/pyscript solution - font file errors >>> “your installed PIL was compiled without libfreetype.” (Similar to this: https://github.com/PiotrMachowski/Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor/issues/20 )

2020-10-24 15:01:05 WARNING (MainThread) [custom_components.pyscript.file.drawtext_onimage.drawtext_onimage] drawtext_onimage: got image_in /config/www/motioneye_files/GiraffeCam1/all_timelapse/lastestGiraffe_wBoxes_cropped.jpg text test text
2020-10-24 15:01:05 ERROR (MainThread) [custom_components.pyscript.file.drawtext_onimage.drawtext_onimage] Exception in <file.drawtext_onimage.drawtext_onimage> line 19:
        font = ImageFont.truetype("arial")
                                  ^
ImportError: The _imagingft C module is not installed

Anyway I’ll take a closer look and report back with how I get on. Newbie so still learning…

PIL / Pillow doesn’t come with all the libraries it might use. It just makes available those that are already installed. So depending on your platform, you’ll need to install libfreetype then re-install PIL. See, for example:

Ok thanks. I think I get it now. Using hassio so not sure what ability I have to do that but will investigate.

Has anyone got NFC tags working with pyscript. Going to be delving into it (using the mobile app to trigger them currently) but not entirely sure how the mobile app handles them I don’t know if that triggers something pyscript can “watch”.

Wondering if anyone else has figured that part out already.