JRuby Scripting Official Helper Library

The JRuby Scripting Helper Library has now been included as an official part of the openHAB repository. Its official github repository now lives under GitHub - openhab/openhab-jruby.

A new version 5.0 of the helper library has been released, which includes some major new features, fixes and some breaking changes. For more details, see the changelog.

The helper library will be installed automatically by the jrubyscripting addon in openHAB 4.0. For openHAB 3.4.x it can be installed by following the installation instructions. For convenience, an alternative version of the addon that performs the automatic installation is available from the marketplace.

Thanks to @broconne for creating this library and to @ccutrer for his major contributions.

3 Likes

Some quick examples of rules written with JRuby Scripting:

rule 'Control light based on door state' do
  changed Door_Sensor, to: [OPEN, CLOSED]
  run { |event| Cupboard_Light << event.state.open? } # Send a boolean command to a Switch Item
end
# Assumption: Motion sensor items are named using the pattern RoomName_Motion
# and Light switch items are named with the pattern RoomName_Light
rule 'Generic motion rule' do
  changed Motion_Sensors.members, to: ON
  run do |event|
    light = items[event.item.name.sub('_Motion', '_Light')] # Lookup item name from a string
    light&.ensure&.on for: 2.minutes
  end
end
rule 'Warn when garage door is open a long time' do
  changed Garage_Door, to: OPEN, for: 15.minutes
  run { Voice.say "Warning, the garage door is open" } # call TTS to the default audio sink
end

If you love one-liners, this is an entire rule:

changed(Door_Sensor) { |event| Cupboard_Light << event.state.open? }

Some of my recent rules:

# I have an item I turn ON when I'm waiting for someone, and want to be notified immediately based on my cameras. I have too many cameras and too many notifications to get this all the time
changed Driveway_Cars, Driveway_Persons do |event|
  next unless ExpectingSomeone_Switch.on?
  next unless event.state?

  if event.state.positive?
    only_every(:minute) do
      notify("Someone is in the driveway")
    end
  end
end
# frozen_string_literal: true

# this rule saves a snapshot from my outdoor cameras every day at solar noon, so I can build a Timelapse without shadows shifting every day

require "cgi"
require "fileutils"
require "open-uri"
require "uri"
require "yaml"

CAMS_TO_SNAPSHOT = %w[
  back_yard_south
  fire_pit
  ...
].freeze

OUTPUT_DIR = "/home/cody/docker/frigate/storage/snapshots"

# astro.items:
# DateTime Sun_Noon { channel="astro:sun:home:noon#start" }
every :day, at: Sun_Noon do
  frigate_config = YAML.load_file("/home/cody/docker/frigate/config.yml")
  snapshot_urls = frigate_config.dig("go2rtc", "streams").to_h do |cam, url|
    url = url.first if url.is_a?(Array)
    url = URI.parse(url)
    url.scheme = "http"
    url.path = "/Streaming/channels/1/picture"
    # have to re-parse so it will be the correct type since the scheme changed
    [cam, URI.parse(url.to_s)]
  end

  today = Date.today
  CAMS_TO_SNAPSHOT.each do |cam|
    snapshot_url = snapshot_urls[cam]

    FileUtils.mkdir_p(File.join(OUTPUT_DIR, cam))

    # credentials have to be passed to #open separately
    creds = [CGI.unescape(snapshot_url.user), CGI.unescape(snapshot_url.password)]
    snapshot_url.userinfo = ""
    logger.info("Downloading snapshot from camera #{cam}")

    snapshot_url.open(http_basic_authentication: creds) do |snapshot|
      IO.copy_stream(snapshot, File.join(OUTPUT_DIR, cam, "#{today}.jpg"))
    end
  rescue => e
    logger.warn("Failed to fetch snapshot from #{cam}: #{e}")
    notify("Failed to fetch snapshot from #{cam} camera")
  end
rescue
  notify("Failed to fetch snapshots from cameras")
  raise
end

This sets up a series of rules for exterior light. The lights come on in the evening to a relatively low level, then even lower at night. But if the cameras detect a person in the vicinity, they’ll immediately brighten. When the cameras no longer detect a person, it takes 15 seconds before they go back to their prior level. It’s a good demonstration of using a method to set up variations on a base rule.

def setup_person_light(dimmer, person_items, evening_level:, night_level: nil, respect_halloween: false)
  night_level ||= evening_level

  OpenHAB::DSL.rule "Set #{dimmer.name} based on occupancy and house mode" do
    on_load
    changed HouseMode_String, *person_items
    run do
      halloween = respect_halloween && Date.today == MonthDay.parse("10-31")

      OpenHAB::DSL.timers.schedule([dimmer, :state_based_on_occupancy_and_mode]) do |timer|
        timer&.cancel

        occupied = person_items.map(&:state).compact.sum.positive?
        case HouseMode_String.state
        when "Day"
          dimmer.ensure.off
        when "Evening", "LateEvening"
          if occupied && !halloween
            dimmer.ensure << 100
          elsif !dimmer.state || dimmer.state < evening_level
            dimmer << evening_level
          else
            next after(15.seconds) { dimmer.ensure << evening_level }
          end
        when "Night"
          if occupied
            dimmer.ensure << 100
          elsif !dimmer.state || dimmer.state < night_level
            dimmer << night_level
          else
            next after(15.seconds) { dimmer.ensure << night_level }
          end
        end
        nil
      end
    end
  end
end

setup_person_light(PorchCans_Dimmer,
                   [Porch_Persons],
                   evening_level: 20,
                   night_level: 0,
                   respect_halloween: true)
setup_person_light(PorchLights_Dimmer,
                   [Porch_Persons],
                   evening_level: 20,
                   respect_halloween: true)
setup_person_light(CoachLights_Dimmer,
                   [Porch_Persons, Driveway_Persons, Basketball_Persons],
                   evening_level: 50,
                   night_level: 25,
                   respect_halloween: true)
setup_person_light(BasketballCoachLights_Dimmer,
                   [Basketball_Persons],
                   evening_level: 100,
                   night_level: 25)

This is my rule for performing licence plate recognition and saving them into an SQLite database as well as keeping the snapshot images for 30 days.

Yes there are other ready-made systems out there (e.g. Plate-Minder) that does it better. This is just to demonstrate what’s possible with jruby scripting along with its ecosystem of available libraries.

# frozen_string_literal: true

require "personal"
require "json"
require "active_support/json"
require "net/http"

gemfile do
  source "https://rubygems.org"
  gem "image_voodoo"
  gem "jdbc-sqlite3"
  gem "sequel"
end

PLATE_PATH = OpenHAB::Core.config_folder / "scripts/plates"
PLATE_FILE = PLATE_PATH.parent / "plates.txt"
PLATE_DB = PLATE_PATH.parent / "plates.db"
OPENALPR_URI = URI("http://192.168.1.10:5000/detect")

# Add more cameras here
CAMERA_IDS = { Street_Camera => 0 }.freeze

DB = Sequel.jdbc("sqlite:#{PLATE_DB}")

DB.create_table? :captures do
  primary_key :capture_id
  DateTime :timestamp, default: Sequel::CURRENT_TIMESTAMP, index: true
  Integer :camera_id, default: 0, index: true
  String :plate, size: 10, index: true
  String :plates # All the plates in the snapshots
  String :snapshot_file
end

DB.create_table? :plates do
  String :plate, size: 10, primary_key: true
  String :notes
end

# This table is to manually correct some common mistakes of familiar plates, e.g. 338ZZZ -> 388ZZZ
DB.create_table? :plate_corrections do
  String :raw_plate, size: 10, primary_key: true
  String :plate, size: 10
end

PLATES = DB[:plates]
CAPTURES = DB[:captures]
PLATE_CORRECTIONS = DB[:plate_corrections]

DB.alter_table(:captures) { add_column :plates, String } unless CAPTURES.columns.include? :plates

#
# Holds a snapshot image with timestamp
#
class Snapshot
  attr_reader :image, :timestamp, :source

  @@camera_mutex = Mutex.new

  # Initialize with an image object
  def initialize(image, source)
    @timestamp = Time.now
    @image = image
    @source = source
  end

  # take a snapshot from the given camera
  def self.capture(camera)
    url = camera.metadata["snapshot_url"].value
    # logger.info "Capture url: #{url}"
    image = @@camera_mutex.synchronize { Net::HTTP.get(URI(url)) }
    Snapshot.new(image, camera)
  end

  # if necessary, calls lpr to recognize the plate
  def plate
    @plate ||= lpr(@image) || ""
  end

  # Return true if we have a valid plate
  def plate?
    !plate.empty?
  end

  def to_s
    "#{timestamp} #{@plate}"
  end
end

#
# Submit the given image to openalpr-http-wrapper service and return the JSON data
#
# @param [Binary] image the binary jpg image data to process
#
# @return [Hash] the returned json data as a Ruby hash, or nil if unsuccessful
#
def openalpr(image)
  uri = OPENALPR_URI
  req = Net::HTTP::Post.new(uri)
  req.set_form([["upload", image, { filename: "image.jpg" }]], "multipart/form-data")
  res = Net::HTTP.start(uri.hostname, uri.port, read_timeout: 2000) { |http| http.request(req) }
  JSON.parse(res.body) if res.is_a? Net::HTTPSuccess
end

#
# Crop the given image
#
# @param [Image] image the binary image data
# @param [Array] rect rectangle to crop, an array with 4 elements: left, top, right, bottom
#
# @return [Image] The cropped image
#
def crop_image(image, rect)
  ImageVoodoo.with_bytes(image).with_crop(*rect).bytes("jpg")
end

#
# Perform the LPR with the given image and
# return the plate with the highest confidence
#
# This will also crop the bottom of the image to remove
# The timestamp that's baked in by the camera
#
# @param [Binary] image the binary jpg image data to process
#
# @return [String] The recognised plate or nil
#
def lpr(image)
  retries = 0
  begin
    # crop the timestamp on the lower part of the image
    image = crop_image(image, [0, 0, 1920, 1000])

    result = openalpr(image)
    result&.dig("results")&.sort_by { |res| res["confidence"] }&.last&.dig("plate")
  rescue => e
    retry if (retries += 1) < 3
    logger.error "1 #{e.message}"
    nil
  end
end

# { camera_id => array of Snapshot }
@snapshots = Hash.new { |hash, key| hash[key] = [] }
@snapshot_mutex = Mutex.new

@processing_queue = []
@processing_mutex = Mutex.new

#
# Save the given snapshot
#   Append the plate to a text file
#
# @param [Snapshot] snapshot The snapshot object to save
#
def save_snapshot(best, snapshots)
  time_str = best.timestamp.strftime("%Y-%m-%d %k:%M:%S.%L")
  lplate = best.plate
  filename = "#{time_str}-#{lplate}.jpg"

  other_plates = snapshots.map(&:plate).grep_v(lplate)

  logger.info { "Saving recognised plate: #{time_str}: #{lplate} - #{other_plates}" }

  File.write(PLATE_FILE, "#{time_str} #{lplate} - #{other_plates}\n", mode: "a") unless lplate.empty?
  File.write(PLATE_PATH / filename, best.image)
  CAPTURES.insert timestamp: best.timestamp,
                  plate: best.plate,
                  plates: other_plates.join(","),
                  snapshot_file: filename,
                  camera_id: CAMERA_IDS[best.source]
end

#
# Given an array of snapshots objects
# Apply LPR and return the longest plate number
#
# @param [Array] snapshots An array of snapshot objects
#
# @return [Snapshot] One snapshot class containing the recognised plate number
#
def best_snapshot(snapshots)
  snapshots.group_by(&:plate).max_by { |_plate, snaps| snaps.length }&.pop&.first
end

#
# Process everything in the processing queue and empty it
#
def process_queue
  snapshots = nil
  @processing_mutex.synchronize do
    snapshots = @processing_queue
    @processing_queue = []
  end
  snapshots = snapshots.select(&:plate?) # throw away snapshots without recognised plates
  best = best_snapshot(snapshots)
  save_snapshot(best, snapshots) if best
end

#
# Add a set of snapshots to the processing queue
# And start a re-settable timer to process the queue shortly
# This will process multiple triggers of the snapshots
# to remove duplicates of the same car triggering multiple times
#
# @param [Array] snaps An array of Snapshot objects
#
def add_snapshots(snaps)
  @processing_mutex.synchronize { @processing_queue.concat snaps }
  after(3.seconds, id: :process_queue) { process_queue }
end

#
# Get multiple snapshots 50ms apart and save them into
# the processing queue
#
# @param [GenericItem] camera The Camera item (Semantic Equipment)
# @param [Integer] count how many snapshots to take
# @param [Duration] interval the interval of the snapshots
# @param [Block] The block to be invoked once all the snapshots have been taken, passing the snapshots array
#
#
def initiate_lpr(camera, num_snapshots: 3, interval: 50.ms)
  after(0.ms) do |timer|
    next if timer.cancelled?

    snaps = nil
    @snapshot_mutex.synchronize do
      last_snapshot = @snapshots[camera].length >= num_snapshots - 1

      timer.reschedule(interval) unless last_snapshot

      logger.debug do
        "Getting snapshot: #{@snapshots[camera].length + 1} #{Time.now.strftime("%Y-%m-%d %k:%M:%S.%L")}"
      end
      @snapshots[camera] << Snapshot.capture(camera)

      snaps = @snapshots.delete(camera) if last_snapshot
    rescue => e
      logger.error "2 #{e.message}"
      timer.cancel
    end
    add_snapshots(snaps) if snaps
  end
end

#
# Check whether a vehicle is detected
# The logic here highly depends on the type of camera in use and
# what data it provides. This particular implementation is for Dahua cameras
#
# @param [Hash] data The JSON data from ipcameras binding
#
# @return [Boolean] true if the data indicates that a vehicle may be detected
#
def vehicle_detected?(data)
  return false unless data

  data["Action"] == "Start" || data["RegionName"] || data.dig("Object", "ObjectType") == "Vehicle"
end

rule "LPR: Capture" do
  updated Street_Camera_EventData
  run do |event|
    if logger.debug? && event.respond_to?(:state)
      logger.debug("Street Camera: #{Street_Camera_LastMotionType.state} #{Street_Camera_EventData.state}")
    end
    json = event.state.to_s
    data = JSON.parse(json)
    camera = event.item.equipment
    next unless vehicle_detected?(data)

    initiate_lpr(camera)
  end
end

#
# Return the file age in days
#
# @param [String] filename
#
# @return [Float] Number of days since the file was created
#
def file_age(filename)
  (Time.now - File.ctime(filename)) / 86_400
end

rule "LPR: Clean up old snapshots" do
  every :day, at: "3am"
  # on_load
  run do
    count = Dir.glob(PLATE_PATH / "*.jpg").select { |f| file_age(f) > 30 }.map { |f| File.delete(f) }.count
    logger.info("Deleted #{count} old LPR snapshot images ") if count.positive?
  end
end

# uncomment to manually trigger it for testing
# initiate_lpr(Street_Camera)