Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion buildingmotif/bin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,17 @@ def app():
@subcommand(
arg("-o", "--output_file", help="Output file for BACnet scan", required=True),
arg("-ip", help="ip address of BACnet network to scan", default=None),
arg(
"--local-broadcast",
action="store_false",
dest="global_broadcast",
help="Limit discovery to local broadcast; defaults to global broadcast",
default=True,
),
)
def scan(args):
"""Scans a BACnet network and generates a JSON file for later processing"""
bacnet_network = BACnetNetwork(args.ip)
bacnet_network = BACnetNetwork(args.ip, global_broadcast=args.global_broadcast)
bacnet_network.dump(Path(args.output_file))


Expand Down
3 changes: 2 additions & 1 deletion buildingmotif/dataclasses/shape_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,8 @@ def get_varname(shape):
pshape,
f"?{get_varname(pshape)}".replace(" ", "_"),
)
path = shacl_path_to_sparql_path(graph, graph.value(pshape, SH.path))
shape_graph = ShapesGraph(graph)
path = shacl_path_to_sparql_path(shape_graph, graph.value(pshape, SH.path))
qMinCount = graph.value(pshape, SH.qualifiedMinCount) or 0

pclass = graph.value(
Expand Down
181 changes: 141 additions & 40 deletions buildingmotif/ingresses/bacnet.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,171 @@
# configure logging output
import asyncio
import logging
import warnings
from functools import cached_property
from typing import Any, Dict, List, Optional, Tuple

from buildingmotif.ingresses.base import Record, RecordIngressHandler

try:
import BAC0
from BAC0.core.devices.Device import Device as BACnetDevice
except ImportError:
logging.critical(
"Install the 'bacnet-ingress' module, e.g. 'pip install buildingmotif[bacnet-ingress]'"
)


from buildingmotif.ingresses.base import Record, RecordIngressHandler

# We do this little rigamarole to avoid BAC0 spitting out a million
# logging messages warning us that we changed the log level, which
# happens when we go through the normal BAC0 log level procedure
logger = logging.getLogger("BAC0_Root.BAC0.scripts.Base.Base")
logger.setLevel(logging.ERROR)


class BACnetNetwork(RecordIngressHandler):
def __init__(self, ip: Optional[str] = None):
def __init__(
self,
ip: Optional[str] = None,
*,
discover_kwargs: Optional[Dict[str, Any]] = None,
global_broadcast: bool = True,
ping: bool = False,
device_kwargs: Optional[Dict[str, Any]] = None,
):
"""
Reads a BACnet network to discover the devices and objects therein

:param ip: IP/mask for the host which is canning the networks,
:param ip: IP/mask for the host which is scanning the network,
defaults to None
:type ip: Optional[str], optional
:param discover_kwargs: Optional kwargs forwarded to BAC0._discover.
:type discover_kwargs: Optional[Dict[str, Any]]
:param global_broadcast: Whether to issue global broadcast Who-Is requests.
:type global_broadcast: bool
:param ping: Whether to ping devices during connect; defaults to False.
:type ping: bool
:param device_kwargs: Optional kwargs forwarded to BAC0.device.
:type device_kwargs: Optional[Dict[str, Any]]
"""
# create the network object; this will handle scans
# Be a good net citizen: do not ping BACnet devices
self.network = BAC0.connect(ip=ip, ping=False)
# initiate discovery of BACnet networks
self.network.discover()

self.devices: List[BACnetDevice] = []
self.objects: Dict[Tuple[str, int], List[dict]] = {}

# for each discovered Device, create a BAC0.device object
# This will read the BACnet objects off of the Device.
# Save the BACnet objects in the objects dictionary
self.objects: Dict[Tuple[str, int], List[Dict[str, Any]]] = {}
discover_kwargs = dict(discover_kwargs or {})
discover_kwargs.setdefault("global_broadcast", global_broadcast)
self._run_async(
self._collect_objects(
ip=ip,
discover_kwargs=discover_kwargs,
ping=ping,
device_kwargs=device_kwargs or {},
)
)

def _run_async(self, coro):
loop = asyncio.new_event_loop()
try:
if self.network.discoveredDevices is None:
warnings.warn("BACnet ingress could not find any BACnet devices")
for (address, device_id) in self.network.discoveredDevices: # type: ignore
# set poll to 0 to avoid reading the points regularly
dev = BAC0.device(address, device_id, self.network, poll=0)
self.devices.append(dev)
self.objects[(address, device_id)] = []

for bobj in dev.points:
obj = bobj.properties.asdict
self._clean_object(obj)
self.objects[(address, device_id)].append(obj)
asyncio.set_event_loop(loop)
loop.run_until_complete(coro)
pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
if pending:
for task in pending:
task.cancel()
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
loop.run_until_complete(loop.shutdown_asyncgens())
finally:
for dev in self.devices:
self.network.unregister_device(dev)
self.network.disconnect()
asyncio.set_event_loop(None)
loop.close()

async def _collect_objects(
self,
*,
ip: Optional[str],
discover_kwargs: Dict[str, Any],
ping: bool,
device_kwargs: Dict[str, Any],
):
device_kwargs.setdefault("poll", -1)
device_kwargs.setdefault("auto_save", False)

async with BAC0.start(ip=ip, ping=ping) as bacnet:
await asyncio.sleep(2)
await bacnet._discover(**discover_kwargs)
await asyncio.sleep(2)

discovered = getattr(bacnet, "discoveredDevices", None)
if not discovered:
warnings.warn("BACnet ingress could not find any BACnet devices")
return

discovered_entries: List[Tuple[Any, Any, Dict[str, Any]]] = []
if isinstance(discovered, dict):
for info in discovered.values():
address = info.get("address")
obj_instance = info.get("object_instance")
device_id = None
if isinstance(obj_instance, tuple) and len(obj_instance) >= 2:
device_id = obj_instance[1]
else:
device_id = info.get("device_id")

if address is None or device_id is None:
logging.warning(
"Skipping discovered device with missing address/device_id: %s",
info,
)
continue

if hasattr(address, "addr"):
address = address.addr
address = str(address)
discovered_entries.append((address, device_id, info))

if not discovered_entries:
warnings.warn("BACnet ingress could not find any BACnet devices")
return

for (address, device_id, _) in discovered_entries:
device = await BAC0.device(address, device_id, bacnet, **device_kwargs)
try:
# keep persistence disabled and quiet for one-shot scans
setattr(device.properties, "auto_save", False)
setattr(device.properties, "clear_history_on_save", False)
setattr(device.properties, "history_size", None)
if hasattr(device, "_log"):
device._log.setLevel(logging.ERROR) # type: ignore[attr-defined]

objects: List[Dict[str, Any]] = []

for bobj in device.points:
obj = bobj.properties.asdict
self._clean_object(obj)
objects.append(obj)

self.objects[(address, device_id)] = objects
finally:
disconnect = getattr(
device, "_disconnect", None # type: ignore[attr-defined]
)
if callable(disconnect):
await disconnect(save_on_disconnect=False, unregister=True)

def _clean_object(self, obj: Dict[str, Any]):
if "name" in obj:
def _normalize(value: Any, path: Tuple[Any, ...]) -> Any:
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, dict):
normalized: Dict[Any, Any] = {}
for key, nested in value.items():
normalized[key] = _normalize(nested, (*path, key))
return normalized
if isinstance(value, (list, tuple, set)):
return [_normalize(v, (*path, idx)) for idx, v in enumerate(value)]

logging.error(
"Ignoring non-serializable BACnet value %r at %s",
value,
" -> ".join(str(p) for p in path),
)
return None

if "name" in obj and isinstance(obj["name"], str):
# remove trailing/leading whitespace from names
obj["name"] = obj["name"].strip()
for key, value in list(obj.items()):
obj[key] = _normalize(value, (obj.get("device"), key))

@cached_property
def records(self) -> List[Record]:
Expand Down
4 changes: 2 additions & 2 deletions docs/explanations/ingresses.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class Record:

The choice of values for the `Record` is up to each `RecordIngressHandler` instance:
- the [`BACnetIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingresses.bacnet.html#buildingmotif.ingresses.bacnet.BACnetNetwork) uses the `rtype` field to differentiate between BACnet Devices and BACnet Objects. The `fields` field contains key-value pairs of different BACnet properties like `name` and `units`
- the [`CSVIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingresses.csv.html#buildingmotif.ingresses.csv.CSVIngress) uses the `rtype` field to denote the CSV filename, and uses the `fields` field to store column-cell values from each row of the CSV file
- the [`CSVIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingresses.csvingress.html#buildingmotif.ingresses.csvingress.CSVIngress) uses the `rtype` field to denote the CSV filename, and uses the `fields` field to store column-cell values from each row of the CSV file

### Graph Ingress Handler

Expand Down Expand Up @@ -56,7 +56,7 @@ The [`BACnetIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingres

### CSV Files

The [`CSVIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingresses.csv.html#buildingmotif.ingresses.csv.CSVIngress) takes a CSV filename as an argument (e.g. `mydata.csv`) and generates a set of `Record`s corresponding to each row in the file.
The [`CSVIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingresses.csvingress.html#buildingmotif.ingresses.csvingress.CSVIngress) takes a CSV filename as an argument (e.g. `mydata.csv`) and generates a set of `Record`s corresponding to each row in the file.

- `rtype`: the filename that contained the row
- `fields`: key-value pairs for the row. The key is the column name; the value is the value of that column at the given row
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/Dockerfile.bacnet
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ WORKDIR /opt

RUN apt update && apt install -y python3 python3-pip && rm -rf /var/lib/apt/lists/*

RUN pip3 install BACpypes
RUN pip3 install BACpypes --break-system-packages

COPY virtual_bacnet.py virtual_bacnet.py
COPY BACpypes.ini .
2 changes: 1 addition & 1 deletion docs/guides/csv-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ tstat2,room345,co2-345,temp-345,sp-345
tstat3,room567,cow-567,temp-567,sp-567
```

We can create a CSV ingress handler using the built-in class ([`CSVIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingresses.csv.html#buildingmotif.ingresses.csv.CSVIngress)):
We can create a CSV ingress handler using the built-in class ([`CSVIngressHandler`](/reference/apidoc/_autosummary/buildingmotif.ingresses.csvingress.html#buildingmotif.ingresses.csvingress.CSVIngress)):

```python
from rdflib import Namespace, Graph
Expand Down
1 change: 0 additions & 1 deletion docs/guides/docker-compose-bacnet.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: "3.4"
services:
device:
build:
Expand Down
13 changes: 10 additions & 3 deletions docs/guides/ingress-bacnet-to-brick.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ RUN apt update \
python3-pip \
&& rm -rf /var/lib/apt/lists/*

RUN pip3 install BACpypes
RUN pip3 install BACpypes --break-system-packages

COPY virtual_bacnet.py virtual_bacnet.py
COPY BACpypes.ini .''')
Expand Down Expand Up @@ -188,12 +188,19 @@ We use the `buildingmotif.ingresses.bacnet.BACnetNetwork` ingress module to pull
```{code-cell} python3
from buildingmotif.ingresses.bacnet import BACnetNetwork

bacnet = BACnetNetwork("172.24.0.1/32") # don't change this if you are using the docker compose setup
bacnet = BACnetNetwork(
"172.24.0.1/32",
discover_kwargs={"global_broadcast": True}, # optional, helps find virtual devices
)
for rec in bacnet.records:
print(rec)
```

Each of these records has an `rtype` field, which is used by the ingress implementation to differentiate between different kinds of records; here it differentiates between BACnet Devices and BACnet Objects, which have different expressions in Brick. The `fields` attribute cotnains arbitrary key-value pairs, again defined by the ingress implementation, which can be interpreted by another ingress module.
```{note}
The `BACnetNetwork` ingress uses BAC0's asynchronous connection helpers under the hood. It opens the network with `BAC0.start` and blocks on discovery by awaiting `bacnet._discover(**discover_kwargs)`, so forwarding options such as `global_broadcast` or `whois` will influence how aggressively the underlying BACnet scan runs. No additional event-loop management is needed when calling the ingress from synchronous code.
```

Each of these records has an `rtype` field, which is used by the ingress implementation to differentiate between different kinds of records; here it differentiates between BACnet Devices and BACnet Objects, which have different expressions in Brick. The `fields` attribute contains arbitrary key-value pairs, again defined by the ingress implementation, which can be interpreted by another ingress module.

## BACnet to Brick: an Initial Model

Expand Down
10 changes: 6 additions & 4 deletions docs/tutorials/model_correction.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ model = Model.create(BLDG, description="This is a test model for a simple buildi
constraints = Library.load(ontology_graph="constraints/constraints.ttl")

# load libraries excluded from the python package (available from the repository)
brick = Library.load(ontology_graph="../../libraries/brick/Brick-subset.ttl")
brick = Library.load(ontology_graph="../../libraries/brick/Brick.ttl")
g36 = Library.load(directory="../../libraries/ashrae/guideline36")
Library.load(ontology_graph="../../libraries/qudt/VOCAB_QUDT-QUANTITY-KINDS-ALL.ttl")
Library.load(ontology_graph="../../libraries/qudt/VOCAB_QUDT-UNITS-ALL.ttl")

# load tutorial 2 model and manifest
model.graph.parse("tutorial2_model.ttl", format="ttl")
Expand All @@ -64,7 +66,7 @@ model.update_manifest(manifest.get_shape_collection())
Let's validate the model again to see what's causing the failure.

```{code-cell}
validation_result = model.validate()
validation_result = model.validate(error_on_missing_imports=False)
print(f"Model is valid? {validation_result.valid}")

# print reasons
Expand Down Expand Up @@ -153,7 +155,7 @@ for templ in generated_templates.get_templates():
We use the same code as before to ask BuildingMOTIF if the model is now valid:

```{code-cell}
validation_result = model.validate()
validation_result = model.validate(error_on_missing_imports=False)
print(f"Model is valid? {validation_result.valid}")
# print reasons
for uri, diffset in validation_result.diffset.items():
Expand Down Expand Up @@ -206,7 +208,7 @@ for templ in generated_templates_sf.get_templates():
We can re-check the validation of the model now:

```{code-cell}
validation_result = model.validate()
validation_result = model.validate(error_on_missing_imports=False)
print(f"Model is valid? {validation_result.valid}")
print(validation_result.report.serialize())

Expand Down
2 changes: 1 addition & 1 deletion docs/tutorials/model_creation.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Currently, libraries in `../../buildingmotif/libraries/` are *included* and libr
```{code-cell}
# load a library
from buildingmotif.dataclasses import Library
brick = Library.load(ontology_graph="../../libraries/brick/Brick-subset.ttl")
brick = Library.load(ontology_graph="../../libraries/brick/Brick.ttl")

# print the first 10 templates
print("The Brick library contains the following templates:")
Expand Down
Loading
Loading