Jump to content

v3.1.14 in production ; cmd & scene controllers


sjenkins

Recommended Posts

Posted

enjoy, let me know if you find any issues.

I have only added dimmers / generic for dim up and done being active in control for programs.  Scenes will take more, and a choice, if we want them to maybe return to the last on positions and trigger the scene, or 100% / 0%.

I am reaching the end of re-writing my Hunter-Douglas plugin to a less ugly Python.  My plan would be to come here next.

VERSION = '3.1.14'
"""
3.1.14
DONE commands for switches, generic, dimmer, garage

3.1.13
DONE prevent direct poll from re-running
DONE add notice if comms check fails
DONE clean-up & debug

 

  • Thanks 1
Posted

Thanks for this! 

Ideally, a Virtual Dimmer would behave the same as a physical dimmer for consistency.

Consider a potential common use case of a virtual controller of a multi-way circuit scene.Virtual dimmer controls and keeps the scene, and physical dimmers all in sync.   

Maybe something along these lines?  

User action (virtual dimmer)

Exact behavior to emulate

Commands to send

(to scene)

Notes

Single tap ON(top paddle)

Go to scene’s programmed On Levels with scene’s ramp

DON (no level parameter)

Mirrors what a physical dimmer sends when used as a scene controller.

Single tap OFF(bottom paddle)

Ramp responders to off per scene link

DOF

Double-tap ON

Fast On (immediate to full)

DFON

Bypasses ramp to 100%.

Double-tap OFF

Fast Off (immediate off)

DFOF

Bypasses ramp.

Press & hold ON (top hold)

Start brightening until release

BMAN (once), then periodic BRT until release, then SMAN

Repeat rate ~300–400 ms? 

Press & hold OFF (bottom hold)

Start dimming until release

BMAN (once), then periodic DIM until release, then SMAN

Same repeat rate.

Set Level(slider/command with %)

Jump scene to specific runtime level

DON/<0–255>

Useful for admin UI or programmatic ontrol.

 

Posted
# nodes/virtual_dimmer.py
import time
import requests
import udi_interface

LOGGER = udi_interface.LOGGER

class IsyRest:
    """Tiny helper to call IoX REST for scene commands."""
    def __init__(self, base_url, user, password, verify_ssl=True, timeout=5):
        self.base = (base_url or '').rstrip('/')
        self.auth = (user, password) if user else None
        self.verify = verify_ssl
        self.timeout = timeout

    def cmd(self, node_or_scene_addr, command, *args):
        if not self.base:
            raise RuntimeError("ISY_BASE_URL not configured")
        url = f"{self.base}/rest/nodes/{node_or_scene_addr}/cmd/{command}"
        if args:
            url += "/" + "/".join(str(a) for a in args)
        r = requests.get(url, auth=self.auth, verify=self.verify, timeout=self.timeout)
        r.raise_for_status()
        return True

class VirtualDimmer(udi_interface.Node):
    """
    Virtual Dimmer that:
    - As a Responder: updates its own ST on DON/DOF/BRT/DIM/etc.
    - As a Controller: forwards DON/DOF/DFON/DFOF/BRT/DIM/BMAN/SMAN to a native IoX scene (group).
    """
    id = 'VLDIM'  # must match nodedefs.xml

    # Drivers:
    #  ST  : 0-100 % level (uom 51)
    #  GV0 : scene/group address this node controls (display)
    #  GV1 : momentary flag 0/1 (if 1, returns to 0% after send)
    drivers = [
        {'driver': 'ST',  'value': 0, 'uom': 51},
        {'driver': 'GV0', 'value': 0, 'uom': 56},
        {'driver': 'GV1', 'value': 0, 'uom': 2},
    ]

    def __init__(self, polyglot, primary, address, name,
                 isy_rest: IsyRest,
                 scene_addr: str = None,
                 momentary: bool = False,
                 step_pct: int = 3):
        super().__init__(polyglot, primary, address, name)
        self.isy = isy_rest
        self.scene_addr = scene_addr  # e.g. 0001, 0012 (group id)
        self.momentary = 1 if momentary else 0
        self.step_pct = max(1, min(20, int(step_pct)))

    # ---------- lifecycle ----------
    def start(self):
        self.setDriver('GV1', self.momentary, report=True, force=True)
        if self.scene_addr:
            try:
                self.setDriver('GV0', int(self.scene_addr), report=True, force=True)
            except Exception:
                # not numeric; ignore—GV0 is display only
                pass
        self.reportDrivers()

    def query(self, command=None):
        self.reportDrivers()

    # ---------- helpers ----------
    @staticmethod
    def pct_to_255(pct: int) -> int:
        pct = max(0, min(100, int(pct)))
        return round(pct * 255 / 100)

    def _set_local_level(self, pct: int):
        pct = max(0, min(100, int(pct)))
        self.setDriver('ST', pct, report=True, force=True)

    def _send_scene(self, cmd, *args):
        if not self.scene_addr:
            LOGGER.debug(f"{self.address}: no scene_addr configured; skip send {cmd} {args}")
            return
        try:
            self.isy.cmd(self.scene_addr, cmd, *args)
        except Exception as e:
            LOGGER.error(f"{self.address}: scene command failed: {cmd} {args} -> {e}")

    def _momentary_reset(self):
        if self.momentary:
            time.sleep(0.4)
            self._set_local_level(0)

    # ---------- command handlers ----------
    # DON (optionally with level 0-100 from Admin Console)
    def cmd_DON(self, command):
        lvl = 100
        if command and 'value' in command:
            try:
                lvl = int(command.get('value'))
            except Exception:
                lvl = 100
        self._set_local_level(lvl)                          # responder behavior
        self._send_scene('DON', self.pct_to_255(lvl))       # controller behavior
        self._momentary_reset()

    def cmd_DOF(self, command):
        self._set_local_level(0)
        self._send_scene('DOF')
        self._momentary_reset()

    def cmd_DFON(self, command):
        self._set_local_level(100)
        self._send_scene('DFON')
        self._momentary_reset()

    def cmd_DFOF(self, command):
        self._set_local_level(0)
        self._send_scene('DFOF')
        self._momentary_reset()

    def cmd_BRT(self, command):
        new_lvl = min(100, int(self.getDriver('ST')) + self.step_pct)
        self._set_local_level(new_lvl)
        self._send_scene('BRT')

    def cmd_DIM(self, command):
        new_lvl = max(0, int(self.getDriver('ST')) - self.step_pct)
        self._set_local_level(new_lvl)
        self._send_scene('DIM')

    def cmd_BMAN(self, command):
        self._send_scene('BMAN')

    def cmd_SMAN(self, command):
        self._send_scene('SMAN')

    # Explicit “Set Level” from programs (0–100)
    def cmd_SETLVL(self, command):
        try:
            pct = int(command.get('value'))
        except Exception:
            pct = 100
        self._set_local_level(pct)
        self._send_scene('DON', self.pct_to_255(pct))
        self._momentary_reset()

    commands = {
        'DON': cmd_DON, 'DOF': cmd_DOF,
        'DFON': cmd_DFON, 'DFOF': cmd_DFOF,
        'BRT': cmd_BRT, 'DIM': cmd_DIM,
        'BMAN': cmd_BMAN, 'SMAN': cmd_SMAN,
        'SETLVL': cmd_SETLVL,
        'QUERY': query,
    }

 

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • Create New...