Skip to content

Commit e0074d2

Browse files
VigneshVSVPFKimmerleMarcusfam-RB
authored
v0.2.11 (#77)
* fast forward doc and examples * add contributing section in README * allow class_member set only for class member parameters * add Zenodo link * Add Node-WoT client example to README (#73) * add main-next-release to test workflows, & some minor generic updates (#75) * resolve issue/69 (#76) --------- Co-authored-by: Patricia Kimmerle <159075491+PFKimmerle@users.noreply.github.com> Co-authored-by: Marcusfam-RB <71035055+Marcusfam-RB@users.noreply.github.com>
1 parent 03b2e8c commit e0074d2

File tree

13 files changed

+465
-128
lines changed

13 files changed

+465
-128
lines changed

.github/workflows/test-dev.yml

+2
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ on:
55
pull_request:
66
branches:
77
- main
8+
- main-next-release
89
push:
910
branches:
1011
- main
12+
- main-next-release
1113

1214
jobs:
1315
test:

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ This release will contain a lot of new features and improvements so that a versi
2525
Not finalised:
2626
- cookie auth & its specification in TD (cookie auth branch)
2727

28+
## [v0.2.11] - 2025-04-25
29+
30+
- new feature - support for JSON files as backup for property values (use with `db_commit`, `db_persist` and `db_init`). Compatible only with JSON serializable properties.
31+
2832
## [v0.2.10] - 2025-04-05
2933

3034
- bug fixes to support `class_member` properties to work with `fget`, `fset` and `fdel` methods. While using custom `fget`, `fset` and `fdel` methods for `class_member`s,

README.md

+98-17
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ For those that understand, this package is a ZMQ/HTTP-RPC.
1313
<br>
1414
[![PyPI - Downloads](https://img.shields.io/pypi/dm/hololinked?label=pypi%20downloads)](https://pypistats.org/packages/hololinked)
1515
[![Conda Downloads](https://img.shields.io/conda/d/conda-forge/hololinked)](https://anaconda.org/conda-forge/hololinked)
16+
<br>
17+
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.15155942.svg)](https://doi.org/10.5281/zenodo.12802841)
18+
[![Discord](https://img.shields.io/discord/1265289049783140464?label=Discord%20Members&logo=discord)](https://discord.com/invite/kEz87zqQXh)
1619

1720
### To Install
1821

@@ -307,22 +310,104 @@ Here one can see the use of `instance_name` and why it turns up in the URL path.
307310

308311
##### NOTE - The package is under active development. Contributors welcome, please check CONTRIBUTING.md and the open issues. Some issues can also be independently dealt without much knowledge of this package.
309312

310-
- [example repository](https://github.com/VigneshVSV/hololinked-examples) - detailed examples for both clients and servers
311-
- [helper GUI](https://github.com/VigneshVSV/thing-control-panel) - view & interact with your object's actions, properties and events.
313+
- [examples repository](https://github.com/hololinked-dev/examples) - detailed examples for both clients and servers
314+
- [helper GUI](https://github.com/hololinked-dev/thing-control-panel) - view & interact with your object's actions, properties and events.
315+
- [live demo](https://control-panel.hololinked.dev/#https://examples.hololinked.dev/simulations/oscilloscope/resources/wot-td) - an example of an oscilloscope available for live test
312316

313317
See a list of currently supported possibilities while using this package [below](#currently-supported).
314318

315319
> You may use a script deployment/automation tool to remote stop and start servers, in an attempt to remotely control your hardware scripts.
316320
317-
### A little more about Usage
321+
### Using APIs and Thing Descriptions
318322

319323
The HTTP API may be autogenerated or adjusted by the user. If your plan is to develop a truly networked system, it is recommended to learn more and
320-
use [Thing Descriptions](https://www.w3.org/TR/wot-thing-description11) to describe your hardware (This is optional and one can still use a classic HTTP client). A Thing Description will be automatically generated if absent as shown in JSON examples above or can be supplied manually. The default end point to fetch thing descriptions are: <br> `http(s)://<host name>/<instance name of the thing>/resources/wot-td` <br>
321-
If there are errors in generation of Thing Description
322-
(mostly due to JSON non-complaint types), one could use: <br> `http(s)://<host name>/<instance name of the thing>/resources/wot-td?ignore_errors=true`
324+
use [Thing Descriptions](https://www.w3.org/TR/wot-thing-description11) to describe your hardware (This is optional and one can still use a classic HTTP client). A Thing Description will be automatically generated if absent as shown in JSON examples above or can be supplied manually. The default end point to fetch thing descriptions are:
325+
326+
```
327+
http(s)://<host-name>/<instance-name-of-the-thing>/resources/wot-td
328+
http(s)://<host-name>/<instance-name-of-the-thing>/resources/wot-td?ignore_errors=true
329+
```
330+
331+
If there are errors in generation of Thing Description (mostly due to JSON non-complaint types), use the second endpoint which may generate at least a partial but useful Thing Description.
332+
333+
### Consuming Thing Descriptions using node-wot (Javascript)
334+
335+
The Thing Descriptions (TDs) can be consumed with Web of Things clients like [node-wot](https://github.com/eclipse-thingweb/node-wot). Suppose an example TD for a device instance named `spectrometer` is available at the following endpoint:
336+
337+
```
338+
http://localhost:8000/spectrometer/resources/wot-td
339+
```
323340

324-
(client docs will be updated here next, also check official docs)
341+
Consume this TD in a Node.js script using Node-WoT:
325342

343+
```js
344+
const { Servient } = require("@node-wot/core");
345+
const HttpClientFactory = require("@node-wot/binding-http").HttpClientFactory;
346+
347+
const servient = new Servient();
348+
servient.addClientFactory(new HttpClientFactory());
349+
350+
servient.start().then((WoT) => {
351+
fetch("http://localhost:8000/spectrometer/resources/wot-td")
352+
.then((res) => res.json())
353+
.then((td) => WoT.consume(td))
354+
.then((thing) => {
355+
thing.readProperty("integration_time").then(async(interactionOutput) => {
356+
console.log("Integration Time: ", await interactionOutput.value());
357+
})
358+
)});
359+
```
360+
This works with both `http://` and `https://` URLs. If you're using HTTPS, just make sure the server certificate is valid or trusted by the client.
361+
362+
```js
363+
const HttpsClientFactory = require("@node-wot/binding-http").HttpsClientFactory;
364+
servient.addClientFactory(new HttpsClientFactory({ allowSelfSigned : true }))
365+
```
366+
You can see an example [here](https://gitlab.com/hololinked/examples/clients/node-clients/phymotion-controllers-app/-/blob/main/src/App.tsx?ref_type=heads#L77).
367+
368+
After consuming the TD, you can:
369+
370+
<details open>
371+
<summary>Read Property</summary>
372+
373+
`thing.readProperty("integration_time").then(async(interactionOutput) => {
374+
console.log("Integration Time:", await interactionOutput.value());
375+
});`
376+
</details>
377+
<details open>
378+
<summary>Write Property</summary>
379+
380+
`thing.writeProperty("integration_time", 2000).then(() => {
381+
console.log("Integration Time updated");
382+
});`
383+
</details>
384+
<details open>
385+
<summary>Invoke Action</summary>
386+
387+
`thing.invokeAction("connect", { serial_number: "S14155" }).then(() => {
388+
console.log("Device connected");
389+
});`
390+
</details>
391+
<details open>
392+
<summary>Subscribe to Event</summary>
393+
394+
`thing.subscribeEvent("intensity_measurement_event", async (interactionOutput) => {
395+
console.log("Received event:", await interactionOutput.value());
396+
});`
397+
</details>
398+
399+
Try out the above code snippets with an online example [using this TD](http://examples.hololinked.net/simulations/spectrometer/resources/wot-td).
400+
> Note: due to reverse proxy buffering, subscribeEvent may take up to 1 minute to receive data. All other operations work fine.
401+
402+
In React, the Thing Description may be fetched inside `useEffect` hook, the client passed via `useContext` hook and the individual operations can be performed in their own callbacks attached to user elements.
403+
<details>
404+
<summary>Links to Examples</summary>
405+
For React examples using Node-WoT, refer to:
406+
407+
- [example1](https://gitlab.com/hololinked/examples/clients/node-clients/phymotion-controllers-app/-/blob/main/src/App.tsx?ref_type=heads#L96)
408+
- [example2](https://gitlab.com/hololinked/examples/clients/node-clients/phymotion-controllers-app/-/blob/main/src/components/movements.tsx?ref_type=heads#L54)
409+
</details>
410+
326411
### Currently Supported
327412

328413
- control method execution and property write with a custom finite state machine.
@@ -335,15 +420,11 @@ If there are errors in generation of Thing Description
335420
- run direct ZMQ-TCP server without HTTP details
336421
- serve multiple objects with the same HTTP server, run HTTP Server & python object in separate processes or the same process
337422

338-
Again, please check examples or the code for explanations. Documentation is being activety improved.
339-
340-
### Currently being worked
341-
342-
- unit tests coverage
343-
- separation of HTTP protocol specification like URL path and HTTP verbs from the API of properties, actions and events and move their customization completely to the HTTP server
344-
- serve multiple things with the same server (unfortunately due to a small oversight it is currently somewhat difficult for end user to serve multiple things with the same server, although its possible. This will be fixed.)
345-
- improving accuracy of Thing Descriptions
346-
- cookie credentials for authentication - as a workaround until credentials are supported, use `allowed_clients` argument on HTTP server which restricts access based on remote IP supplied with the HTTP headers. This wont still help you in public networks or modified/non-standard HTTP clients.
347-
423+
Again, please check examples or the code for explanations. Documentation is being actively improved.
348424

425+
### Contributing
349426

427+
See [organization info](https://github.com/hololinked-dev) for details regarding contributing to this package. There is:
428+
- [discord group](https://discord.com/invite/kEz87zqQXh)
429+
- [weekly meetings](https://github.com/hololinked-dev/#monthly-meetings) and
430+
- [project planning](https://github.com/orgs/hololinked-dev/projects/4) to discuss activities around this repository.

doc

Submodule doc updated from c0c4a8d to d4e965b

examples

hololinked/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.2.10"
1+
__version__ = "0.2.11"

hololinked/param/parameterized.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -409,8 +409,10 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin
409409
raise_ValueError("Read-only parameter cannot be set/modified.", self)
410410

411411
value = self.validate_and_adapt(value)
412-
413-
if self.class_member and obj is not self.owner: # safety check
412+
413+
if not self.class_member and obj is self.owner:
414+
raise AttributeError("Cannot set instance parameter on class")
415+
if self.class_member and obj is not self.owner:
414416
obj = self.owner
415417

416418
old = NotImplemented
@@ -1807,8 +1809,13 @@ def __setattr__(mcs, attribute_name : str, value : typing.Any) -> None:
18071809
if attribute_name != '_param_container' and attribute_name != '__%s_params__' % mcs.__name__:
18081810
parameter = mcs.parameters.descriptors.get(attribute_name, None)
18091811
if parameter: # and not isinstance(value, Parameter):
1810-
parameter.__set__(mcs, value)
1811-
return
1812+
try:
1813+
parameter.__set__(mcs, value)
1814+
return
1815+
except AttributeError as ex:
1816+
# raised for class attribute
1817+
if not str(ex).startswith("Cannot set instance parameter on class"):
1818+
raise ex from None
18121819
return type.__setattr__(mcs, attribute_name, value)
18131820

18141821
def __getattr__(mcs, attribute_name : str) -> typing.Any:

hololinked/server/json_storage.py

+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import os
2+
import threading
3+
from typing import Any, Dict, List, Optional, Union
4+
from .serializers import JSONSerializer
5+
from .property import Property
6+
from ..param import Parameterized
7+
8+
9+
class ThingJsonStorage:
10+
"""
11+
JSON-based storage engine composed within ``Thing``. Carries out property operations such as storing and
12+
retrieving values from a plain JSON file.
13+
14+
Parameters
15+
----------
16+
filename : str
17+
Path to the JSON file to use for storage.
18+
instance : Parameterized
19+
The ``Thing`` instance which uses this storage. Required to read default property values when
20+
creating missing properties.
21+
serializer : JSONSerializer, optional
22+
Serializer used for encoding and decoding JSON data. Defaults to an instance of ``JSONSerializer``.
23+
"""
24+
def __init__(self, filename: str, instance: Parameterized, serializer: Optional[Any]=None):
25+
self.filename = filename
26+
self.thing_instance = instance
27+
self.instance_name = instance.instance_name
28+
self._serializer = serializer or JSONSerializer()
29+
self._lock = threading.RLock()
30+
self._data = self._load()
31+
32+
def _load(self) -> Dict[str, Any]:
33+
"""
34+
Load and decode data from the JSON file.
35+
36+
Returns
37+
-------
38+
value: dict
39+
A dictionary of all stored properties. Empty if the file does not exist or cannot be decoded.
40+
"""
41+
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
42+
return {}
43+
try:
44+
with open(self.filename, 'rb') as f:
45+
raw_bytes = f.read()
46+
if not raw_bytes:
47+
return {}
48+
return self._serializer.loads(raw_bytes)
49+
except Exception:
50+
return {}
51+
52+
def _save(self):
53+
"""
54+
Encode and write data to the JSON file.
55+
"""
56+
raw_bytes = self._serializer.dumps(self._data)
57+
with open(self.filename, 'wb') as f:
58+
f.write(raw_bytes)
59+
60+
def get_property(self, property: Union[str, Property]) -> Any:
61+
"""
62+
Fetch a single property.
63+
64+
Parameters
65+
----------
66+
property: str | Property
67+
string name or descriptor object
68+
69+
Returns
70+
-------
71+
value: Any
72+
property value
73+
"""
74+
name = property if isinstance(property, str) else property.name
75+
if name not in self._data:
76+
raise KeyError(f"property {name} not found in JSON storage")
77+
with self._lock:
78+
return self._data[name]
79+
80+
def set_property(self, property: Union[str, Property], value: Any) -> None:
81+
"""
82+
change the value of an already existing property.
83+
84+
Parameters
85+
----------
86+
property: str | Property
87+
string name or descriptor object
88+
value: Any
89+
value of the property
90+
"""
91+
name = property if isinstance(property, str) else property.name
92+
with self._lock:
93+
self._data[name] = value
94+
self._save()
95+
96+
def get_properties(self, properties: Dict[Union[str, Property], Any]) -> Dict[str, Any]:
97+
"""
98+
get multiple properties at once.
99+
100+
Parameters
101+
----------
102+
properties: List[str | Property]
103+
string names or the descriptor of the properties as a list
104+
105+
Returns
106+
-------
107+
value: Dict[str, Any]
108+
property names and values as items
109+
"""
110+
names = [key if isinstance(key, str) else key.name for key in properties.keys()]
111+
with self._lock:
112+
return {name: self._data.get(name) for name in names}
113+
114+
def set_properties(self, properties: Dict[Union[str, Property], Any]) -> None:
115+
"""
116+
change the values of already existing few properties at once
117+
118+
Parameters
119+
----------
120+
properties: Dict[str | Property, Any]
121+
string names or the descriptor of the property and any value as dictionary pairs
122+
"""
123+
with self._lock:
124+
for obj, value in properties.items():
125+
name = obj if isinstance(obj, str) else obj.name
126+
self._data[name] = value
127+
self._save()
128+
129+
def get_all_properties(self) -> Dict[str, Any]:
130+
"""
131+
read all properties of the ``Thing`` instance.
132+
"""
133+
with self._lock:
134+
return dict(self._data)
135+
136+
def create_missing_properties(self, properties: Dict[str, Property],
137+
get_missing_property_names: bool = False) -> Optional[List[str]]:
138+
"""
139+
create any and all missing properties of ``Thing`` instance
140+
141+
Parameters
142+
----------
143+
properties: Dict[str, Property]
144+
descriptors of the properties
145+
146+
Returns
147+
-------
148+
missing_props: List[str]
149+
list of missing properties if get_missing_property_names is True
150+
"""
151+
missing_props = []
152+
with self._lock:
153+
existing_props = self.get_all_properties()
154+
for name, new_prop in properties.items():
155+
if name not in existing_props:
156+
self._data[name] = getattr(self.thing_instance, new_prop.name)
157+
missing_props.append(name)
158+
self._save()
159+
if get_missing_property_names:
160+
return missing_props
161+
162+
163+
__all__ = [
164+
ThingJsonStorage.__name__,
165+
]

hololinked/server/td.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import typing, inspect
22
from dataclasses import dataclass, field
33

4-
from hololinked.server.eventloop import EventLoop
54

65

76
from .constants import JSON, JSONSerializable
@@ -12,6 +11,7 @@
1211
from .property import Property
1312
from .thing import Thing
1413
from .state_machine import StateMachine
14+
from .eventloop import EventLoop
1515

1616

1717

0 commit comments

Comments
 (0)