Skip to content

Properties In-Depth

Properties expose python attributes to clients & support custom get-set(-delete) operations. Further, change events can be subscribed/observed to be automatically informed of a change in the value of a property. hololinked uses param under the hood to implement properties, which in turn uses the descriptor protocol.

Note

Python's own property is not supported for remote access due to limitations in using foreign attributes within the property object. Said limitation causes redundancy with the existing implementation of Property class, nevertheless, the term Property (with capital 'P') is used to comply with the terminology of Web of Things.

Untyped/Custom Typed Property

API Reference

To make a property take any python value, use the base class Property:

Untyped Property
from hololinked.server import Thing, Property

class TestObject(Thing):

    my_untyped_serializable_attribute = Property(default=frozenset([2, 3, 4]), 
                allow_None=True, doc="this property can hold any python value")

    def __init__(self, *, id: str, **kwargs) -> None:
        super().__init__(id=id, **kwargs)
        self.my_untyped_serializable_attribute = kwargs.get('some_prop', None)

One can also pass the property value to the parent's __init__ to auto-set or auto-invoke the setter at __init__:

init
from hololinked.server import Thing, Property

class TestObject(Thing):

    my_untyped_serializable_attribute = Property(default=frozenset([2, 3, 4]), 
                allow_None=True, doc="this property can hold any python value")

    def __init__(self, *, id: str, my_untyped_serializable_attribute : Any, 
                **kwargs) -> None:
        super().__init__(
            id=id, 
            my_untyped_serializable_attribute=my_untyped_serializable_attribute,
            **kwargs
        )

As previously stated, by default, a data container is auto allocated at the instance level. One can supply a custom getter-setter if necessary, especially when applying the property directly onto the hardware. The object (descriptor instance of Property) that performs the get-set operations or auto-allocation of an internal instance variable for the property can be accessed by the instance under self.properties.descriptors["<property name>"]:

Custom Typed Property
import numpy
from hololinked.server import Thing, Property

class TestObject(Thing):

    my_custom_typed_serializable_attribute = Property(default=[2, "foo"], 
                allow_None=False, doc="""this property can hold some 
                                        values based on get-set overload""")

    @my_custom_typed_serializable_attribute.getter
    def get_prop(self):
        try:
            return self._foo     
        except AttributeError:
            return self.properties.descriptors[
                    "my_custom_typed_serializable_attribute"].default 

    @my_custom_typed_serializable_attribute.setter
    def set_prop(self, value):
        if isinstance(value, (list, tuple)) and len(value) < 100:
            for index, val in enumerate(value): 
                if not isinstance(val, (str, int, type(None))):
                    raise ValueError(f"Value at position {index} not " + 
                            "acceptable member type of " +
                            "my_custom_typed_serializable_attribute " +
                            f"but type {type(val)}")
            self._foo = value
        elif isinstance(value, numpy.ndarray):
            self._foo = value
        else:
            raise TypeError(f"Given type is not list or tuple for " +
                    f"my_custom_typed_serializable_attribute but type {type(value)}")

    def __init__(self, *, id: str, **kwargs) -> None:
        super().__init__(id=id, **kwargs)
        self.my_untyped_serializable_attribute = kwargs.get('some_prop', None)
        self.my_custom_typed_serializable_attribute = [1, 2, 3, ""]

The value of the property must be serializable to be read by the clients. Read the serializer section for further details & customization.

Warning

Please dont use the same name as the property for the property's getter and setter methods, otherwise the property will be replaced with these methods and you are left only with the method, not the property

from hololinked.server import Thing, Property

class TestObject(Thing):
    my_property = Property(default=[2, "foo"], allow_None=False, 
                    doc="this property can hold some values based on get-set overload")

    @my_property.getter
    def my_property(self): 
        # wrong - please dont use the property's name for the getter method
        return self._foo     

To make a property only locally accessible, set remote=False, i.e. such a property will not accessible on the network.

Predefined Typed Properties

API Reference

Certain typed properties are already available in hololinked.server.properties, defined by param:

Property Class Type Options
String str comply to regex
Number float, integer min & max bounds, inclusive bounds, crop to bounds, multiples
Integer integer same as Number
Boolean bool tristate if allow_None=True
Iterable iterables length/bounds, item_type, dtype (allowed type of the iterable itself)
Tuple tuple same as iterable
List list same as iterable
Selector one of many objects allowed list of objects
TupleSelector one or more of many objects allowed list of objects
ClassSelector class, subclass or instance comply to instance only or class/subclass only
Path, Filename, Foldername path, filename & folder names
Date datetime format
TypedList typed list typed appends, extends
TypedDict, TypedKeyMappingsDict typed dictionary typed updates, assignments

An example:

Typed Properties
from hololinked.server.properties import String, Number, Selector, Boolean, List

class OceanOpticsSpectrometer(Thing):

    nonlinearity_correction = Boolean(default=False, 
                                doc="""set True for auto CCD nonlinearity 
                                    correction. Not supported by all models,
                                    like STS.""") # type: bool

    trigger_mode = Selector(objects=[0, 1, 2, 3, 4], default=0, 
                        doc="""0 = normal/free running, 
                            1 = Software trigger, 2 = Ext. Trigger Level,
                            3 = Ext. Trigger Synchro/ Shutter mode,
                            4 = Ext. Trigger Edge""") # type: int

    @trigger_mode.setter 
    def apply_trigger_mode(self, value : int):
        self.device.trigger_mode(value)
        self._trigger_mode = value 

    @trigger_mode.getter 
    def get_trigger_mode(self):
        try:
            return self._trigger_mode
        except:
            return self.parameters["trigger_mode"].default 

For typed properties, before the setter is invoked, the value is internally validated. The return value of getter method is never validated and is left to the developer's or the client object's caution.

Schema Constrained Property

For complicated data structures, one can use pydantic or JSON schema based type definition and validation. Set the model argument to define the type:

Properties Using Schema - pydantic
from hololinked.server import Property, Thing
from typing import Annotated, Tuple
from pydantic import BaseModel, Field
from pyueye import ueye


class Rect(BaseModel):
    x : Annotated[int, Field(default=0, ge=0)]
    y : Annotated[int, Field(default=0, ge=0)]
    width : Annotated[int, Field(default=0, gt=0)]
    height: Annotated[int, Field(default=0, gt=0)]


class UEyeCamera(Thing):

    def get_aoi(self) -> Rect:
        """Get current AOI from camera as Rect object (with x, y, width, height)"""
        rect_aoi = ueye.IS_RECT()
        ret = ueye.is_AOI(self.handle, ueye.IS_AOI_IMAGE_GET_AOI,
                        rect_aoi, ueye.sizeof(rect_aoi))
        assert return_code_OK(self.handle, ret)
        return Rect(
                x=rect_aoi.s32X.value,
                y=rect_aoi.s32Y.value,
                width=rect_aoi.s32Width.value,
                height=rect_aoi.s32Height.value
            )

    def set_aoi(self, value: Rect) -> None:
        """Set camera AOI. Specify as x,y,width,height or a tuple
        (x, y, width, height) or as Rect object."""
        rect_aoi = ueye.IS_RECT()
        rect_aoi.s32X = ueye.int(value.x)
        rect_aoi.s32Y = ueye.int(value.y)
        rect_aoi.s32Width = ueye.int(value.width)
        rect_aoi.s32Height = ueye.int(value.height)

        ret = ueye.is_AOI(self.handle, ueye.IS_AOI_IMAGE_SET_AOI,
                             rect_aoi, ueye.sizeof(rect_aoi))
        assert return_code_OK(self.handle, ret)

    AOI = Property(fget=get_aoi, fset=set_aoi, model=Rect,
                doc="Area of interest within the image",) # type: Rect
Properties Using Schema - JSON schema
import ctypes
from picosdk.ps6000 import ps6000 as ps
from picosdk.functions import assert_pico_ok

trigger_schema = {
    'type': 'object',
    'properties' : {
        'enabled' : { 'type': 'boolean' },
        'channel' : { 
            'type': 'string', 
            'enum': ['A', 'B', 'C', 'D', 'EXTERNAL', 'AUX'] 
            # include both external and aux for 5000 & 6000 series
            # let the device driver will check if the channel is valid for the series
        },
        'threshold' : { 'type': 'number' },
        'adc' : { 'type': 'boolean' },
        'direction' : { 
            'type': 'string', 
            'enum': ['above', 'below', 'rising', 'falling', 'rising_or_falling'] 
        },
        'delay' : { 'type': 'integer' },
        'auto_trigger' : { 
            'type': 'integer', 
            'minimum': 0 
        }
    },
    "description" : "Trigger settings for a single channel of the picoscope",
}

class Picoscope(Thing):

    trigger = Property(doc="Trigger settings",
                    model=trigger_schema) # type: dict

    @trigger.setter
    def set_trigger(self, value : dict) -> None:
        channel = value["channel"].upper()
        direction = value["direction"].upper()
        enabled = ctypes.c_int16(int(value["enabled"]))
        delay = ctypes.c_int32(value["delay"])
        direction = ps.PS6000_THRESHOLD_DIRECTION[f'PS6000_{direction}']
        if channel in ['A', 'B', 'C', 'D']:
            channel = ps.PS6000_CHANNEL['PS6000_CHANNEL_{}'.format(
                                        channel)]
        else:
            channel = ps.PS6000_CHANNEL['PS6000_TRIGGER_AUX']
        if not value["adc"]:
            if channel in ['A', 'B', 'C', 'D']:
                threshold = int(threshold * self.max_adc * 1e3
                            / self.ranges[self.channel_settings[channel]['v_range']])
            else:
                threshold = int(self.max_adc/5)
        threshold = ctypes.c_int16(threshold)
        auto_trigger = ctypes.c_int16(int(auto_trigger))
        self._status['trigger'] = ps.ps6000SetSimpleTrigger(self._ct_handle,
                                    enabled, channel, threshold, direction, 
                                    delay, auto_trigger)
        assert_pico_ok(self._status['trigger'])