Skip to content

Commit ed76b0d

Browse files
author
Joel Collins
committed
Initial commit
1 parent c254d68 commit ed76b0d

File tree

11 files changed

+705
-0
lines changed

11 files changed

+705
-0
lines changed

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# LabThings Python Client
2+
3+
This is an (extremely early) minimal Python client for LabThings devices
4+
5+
## Usage example
6+
7+
```
8+
import atexit
9+
from labthings_client.discovery import ThingBrowser
10+
11+
# Create a Thing discoverer
12+
browser = ThingBrowser().open()
13+
# Close the discoverer when this script exits
14+
atexit.register(browser.close)
15+
16+
# Wait for the first found LabThing on the network
17+
thing = browser.wait_for_first()
18+
```
19+
20+
### Managing properties
21+
22+
```
23+
>>> thing.properties
24+
{'pdfComponentMagicDenoise': <affordances.Property object at 0x00000288F4095548>}
25+
>>> thing.properties.pdfComponentMagicDenoise.set(500)
26+
500
27+
>>> thing.properties.pdfComponentMagicDenoise.get()
28+
500
29+
>>>
30+
```
31+
32+
33+
### Managing actions
34+
35+
```
36+
>>> thing.actions
37+
{'averageDataAction': <affordances.Action object at 0x00000288F40955C8>}
38+
>>> thing.actions.averageDataAction.args
39+
{'type': <class 'dict'>,
40+
'properties': {'n': {'format': 'int32',
41+
'required': True,
42+
'type': <class 'int'>},
43+
'optlist': {'example': [1, 2, 3],
44+
'items': {'format': 'int32', 'type': <class 'int'>},
45+
'nullable': True,
46+
'required': False,
47+
'type': <class 'list'>}},
48+
}
49+
>>> thing.actions.averageDataAction(n=10)
50+
<tasks.ActionTask object at 0x00000288F40D1348>
51+
>>> thing.actions.averageDataAction(n=10).wait()
52+
[0.0013352326078147302, 0.0008734229673564006, 0.0009756767699519994, 0.0008614760409831329, ...
53+
>>>
54+
```

labthings_client/__init__.py

Whitespace-only changes.

labthings_client/affordances.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import requests
2+
3+
from td_parsers import find_self_link
4+
from json_typing import json_to_typing_basic
5+
from tasks import ActionTask
6+
7+
class Affordance:
8+
def __init__(self, affordance_description: dict, base_url: str = ""):
9+
self.base_url = base_url.strip("/")
10+
self.affordance_description = affordance_description
11+
12+
self.url_suffix = find_self_link(self.affordance_description.get("links"))
13+
self.self_url = f"{self.base_url}{self.url_suffix}"
14+
15+
self.description = self.affordance_description.get("description")
16+
17+
18+
def find_verbs(self):
19+
"""Verify available HTTP methods
20+
21+
Returns:
22+
[list] -- List of HTTP verb strings
23+
"""
24+
return requests.options(self.self_url).headers['allow'].split(", ")
25+
26+
27+
class Property(Affordance):
28+
def __init__(self, affordance_description: dict, base_url: str = ""):
29+
Affordance.__init__(self, affordance_description, base_url=base_url)
30+
31+
self.read_only = self.affordance_description.get("readOnly")
32+
self.write_only = self.affordance_description.get("writeOnly")
33+
34+
def __call__(self, *args, **kwargs):
35+
return self.get(*args, **kwargs)
36+
37+
def get(self):
38+
if not self.write_only:
39+
r = requests.get(self.self_url)
40+
r.raise_for_status()
41+
return r.json()
42+
else:
43+
raise AttributeError("Can't read attribute, is write-only")
44+
45+
def set(self, *args, **kwargs):
46+
return self.put(*args, **kwargs)
47+
48+
def put(self, value):
49+
if not self.read_only:
50+
r = requests.put(self.self_url, json=value or {})
51+
r.raise_for_status()
52+
return r.json()
53+
else:
54+
raise AttributeError("Can't set attribute, is read-only")
55+
56+
def post(self, value):
57+
if not self.read_only:
58+
r = requests.post(self.self_url, json=value or {})
59+
r.raise_for_status()
60+
return r.json()
61+
else:
62+
raise AttributeError("Can't set attribute, is read-only")
63+
64+
def delete(self):
65+
if not self.read_only:
66+
r = requests.delete(self.self_url)
67+
r.raise_for_status()
68+
return r.json()
69+
else:
70+
raise AttributeError("Can't delete attribute, is read-only")
71+
72+
73+
class Action(Affordance):
74+
def __init__(self, affordance_description: dict, base_url: str = ""):
75+
Affordance.__init__(self, affordance_description, base_url=base_url)
76+
77+
self.args = json_to_typing_basic(self.affordance_description.get("input", {}))
78+
79+
def __call__(self, *args, **kwargs):
80+
return self.post(*args, **kwargs)
81+
82+
def post(self, *args, **kwargs):
83+
84+
# Only accept a single positional argument, at most
85+
if len(args) > 1:
86+
raise ValueError("If passing parameters as a positional argument, the only argument must be a single dictionary")
87+
88+
# Single positional argument MUST be a dictionary
89+
if args and not isinstance(args[0], dict):
90+
raise TypeError("If passing parameters as a positional argument, the argument must be a dictionary")
91+
92+
# Use positional dictionary as parameters base
93+
if args:
94+
params = args[0]
95+
else:
96+
params = {}
97+
98+
params.update(kwargs)
99+
100+
r = requests.post(self.self_url, json=params or {})
101+
r.raise_for_status()
102+
103+
return ActionTask(r.json())

labthings_client/discovery.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from zeroconf import ServiceBrowser, Zeroconf
2+
import ipaddress
3+
import logging
4+
import time
5+
6+
from pprint import pprint
7+
8+
from thing import FoundThing
9+
10+
class Browser:
11+
def __init__(self, service="labthing", protocol="tcp"):
12+
self.service_record = f"_{service}._{protocol}.local."
13+
14+
self.services = {}
15+
16+
self.add_service_callbacks = set()
17+
self.remove_service_callbacks = set()
18+
19+
self._zeroconf = Zeroconf()
20+
self._browser = None
21+
22+
def __enter__(self):
23+
self.open()
24+
return self
25+
26+
def __exit__(self ,type, value, traceback):
27+
return self.close()
28+
29+
def open(self):
30+
self._browser = ServiceBrowser(self._zeroconf, self.service_record, self)
31+
return self
32+
33+
def close(self, *args, **kwargs):
34+
logging.info(f"Closing browser {self}")
35+
return self._zeroconf.close(*args, **kwargs)
36+
37+
def remove_service(self, zeroconf, type, name):
38+
service = zeroconf.get_service_info(type, name)
39+
if name in self.services:
40+
for callback in self.remove_service_callbacks:
41+
callback(self.services[name])
42+
del self.services[name]
43+
44+
def add_service(self, zeroconf, type, name):
45+
service = zeroconf.get_service_info(type, name)
46+
self.services[name] = parse_service(service)
47+
for callback in self.add_service_callbacks:
48+
callback(self.services[name])
49+
50+
### TODO: The names of these functions are an abomination and should be renamed
51+
def add_add_service_callback(self, callback, run_on_existing: bool = True):
52+
self.add_service_callbacks.add(callback)
53+
if run_on_existing:
54+
for service in self.services:
55+
callback(service)
56+
57+
def remove_add_service_callback(self, callback):
58+
self.add_service_callbacks.discard(callback)
59+
60+
def add_remove_service_callback(self, callback):
61+
self.remove_service_callbacks.add(callback)
62+
63+
def remove_add_service_callback(self, callback):
64+
self.remove_service_callbacks.discard(callback)
65+
66+
67+
class ThingBrowser(Browser):
68+
def __init__(self, *args, **kwargs):
69+
Browser.__init__(self, *args, **kwargs)
70+
self._things = set()
71+
self.add_add_service_callback(self.add_service_to_things)
72+
self.add_remove_service_callback(self.remove_service_from_things)
73+
74+
@property
75+
def things(self):
76+
return list(self._things)
77+
78+
def add_service_to_things(self, service):
79+
self._things.add(service_to_thing(service))
80+
81+
def remove_service_from_things(self, service):
82+
discards = set()
83+
for thing in self._things:
84+
if thing.name == service.get("name"):
85+
discards.add(thing)
86+
for discard_thing in discards:
87+
self._things.discard(discard_thing)
88+
89+
def wait_for_first(self):
90+
while len(self.things) == 0:
91+
time.sleep(0.1)
92+
return self.things[0]
93+
94+
def parse_service(service):
95+
properties = {}
96+
for k, v in service.properties.items():
97+
properties[k.decode()] = v.decode()
98+
99+
return {
100+
"address": ipaddress.ip_address(service.address),
101+
"addresses": {ipaddress.ip_address(a) for a in service.addresses},
102+
"port": service.port,
103+
"name": service.name,
104+
"server": service.server,
105+
"properties": properties,
106+
}
107+
108+
109+
def service_to_thing(service: dict):
110+
if not ("addresses" in service or "port" in service or "path" in service.get("properties", {})):
111+
raise KeyError("Invalid service. Missing keys.")
112+
return FoundThing(service.get("name"), service.get("addresses"), service.get("port"), service.get("properties").get("path"))
113+
114+
115+
if __name__ == "__main__":
116+
import atexit
117+
import time
118+
119+
logging.getLogger().setLevel(logging.DEBUG)
120+
121+
browser = ThingBrowser().open()
122+
atexit.register(browser.close)
123+
124+
thing = browser.wait_for_first()

labthings_client/json_typing.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from typing import Any
2+
import copy
3+
4+
def json_to_typing_basic(schema: dict):
5+
6+
# Copy so we don't start popping keys from the main description
7+
working_schema = copy.deepcopy(schema)
8+
9+
# Get basic type (will be recursive for objects/arrays)
10+
json_type = working_schema.pop("type", "any")
11+
python_type = Any
12+
13+
description = {}
14+
15+
if json_type == "boolean":
16+
python_type = bool
17+
elif json_type == "integer":
18+
python_type = int
19+
elif json_type == "number":
20+
python_type = float
21+
elif json_type == "null":
22+
python_type = NoneType
23+
elif json_type == "string":
24+
python_type = str
25+
elif json_type == "object":
26+
python_type = dict
27+
description["properties"] = {k: json_to_typing_basic(v) for k, v in working_schema.pop("properties", {}).items()}
28+
elif json_type == "array":
29+
python_type = list
30+
description["items"] = json_to_typing_basic(working_schema.pop("items",{}))
31+
32+
description["type"] = python_type
33+
34+
# Add in extra unused parameters
35+
description.update(working_schema)
36+
37+
return description

labthings_client/tasks.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import requests
2+
3+
class ActionTask:
4+
def __init__(self, task_description: dict):
5+
self.task_description = task_description
6+
self.self_url = self.task_description.get("href")
7+
8+
self._value = None
9+
10+
def update(self):
11+
r = requests.get(self.self_url)
12+
r.raise_for_status()
13+
self.task_description = r.json()
14+
15+
@property
16+
def status(self):
17+
return self.task_description.get("status")
18+
19+
@property
20+
def log(self):
21+
return self.task_description.get("log")
22+
23+
@property
24+
def value(self):
25+
if not self._value:
26+
if not self.task_description.get("return"):
27+
self.update()
28+
self._value = self.task_description.get("return")
29+
return self._value
30+
31+
def wait(self):
32+
"""Poll the task until it finishes, and return the return value"""
33+
log_n = 0
34+
while self.status in ["running", "idle"]:
35+
self.update()
36+
while len(self.log) > log_n:
37+
d = self.log[log_n]
38+
logging.log(d["levelno"], d["message"])
39+
log_n += 1
40+
return self.value

labthings_client/td_parsers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
def find_self_link(links_list: list):
2+
return_link = ""
3+
print(links_list)
4+
# Look for an explicit "self" link
5+
for link in links_list:
6+
if link.get("rel") == "self":
7+
return link.get("href")
8+
# Failing that, look for a link with no rel
9+
for link in links_list:
10+
if link.get("rel") is None:
11+
return link.get("href")
12+
# Failing that, return the first link
13+
if len(links_list) > 0:
14+
return links_list[0].get("href")
15+
# Failing even that, return empty string
16+
return ""

0 commit comments

Comments
 (0)