Skip to content

Commit 81b99e7

Browse files
committed
feat(ashrae bacnet): initial work on generic BACnet driver
1 parent fb7cccd commit 81b99e7

File tree

5 files changed

+330
-5
lines changed

5 files changed

+330
-5
lines changed

drivers/ashrae/bacnet.cr

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
require "placeos-driver"
2+
require "socket"
3+
require "./bacnet_models"
4+
5+
class Ashrae::BACnet < PlaceOS::Driver
6+
generic_name :BACnet
7+
descriptive_name "BACnet Connector"
8+
description %(makes BACnet data available to other drivers in PlaceOS)
9+
10+
# Hookup dispatch to the BACnet BBMD device
11+
uri_base "ws://dispatch/api/server/udp_dispatch?port=47808&accept=192.168.0.1"
12+
13+
default_settings({
14+
dispatcher_key: "secret",
15+
bbmd_ip: "192.168.0.1",
16+
known_devices: [{
17+
ip: "192.168.86.25",
18+
id: 389999,
19+
net: 0x0F0F,
20+
addr: "0A",
21+
}],
22+
verbose_debug: false,
23+
})
24+
25+
def websocket_headers
26+
dispatcher_key = setting?(String, :dispatcher_key)
27+
HTTP::Headers{
28+
"Authorization" => "Bearer #{dispatcher_key}",
29+
"X-Module-ID" => module_id,
30+
}
31+
end
32+
33+
getter! udp_server : UDPSocket
34+
getter! bacnet_client : ::BACnet::Client::IPv4
35+
getter! device_registry : ::BACnet::Client::DeviceRegistry
36+
37+
alias DeviceInfo = ::BACnet::Client::DeviceRegistry::DeviceInfo
38+
39+
@packets_processed : UInt64 = 0_u64
40+
@verbose_debug : Bool = false
41+
@bbmd_ip : Socket::IPAddress = Socket::IPAddress.new("127.0.0.1", 0xBAC0)
42+
@devices : Hash(UInt32, DeviceInfo) = {} of UInt32 => DeviceInfo
43+
@mutex : Mutex = Mutex.new(:reentrant)
44+
45+
def on_load
46+
# We only use dispatcher for broadcast messages, a local port for primary comms
47+
server = UDPSocket.new
48+
server.bind "0.0.0.0", 0xBAC0
49+
@udp_server = server
50+
51+
# Hook up the client to the transport
52+
client = ::BACnet::Client::IPv4.new
53+
client.on_transmit do |message, address|
54+
if address.address == Socket::IPAddress::BROADCAST
55+
logger.debug { "sending broadcase message #{message.inspect}" }
56+
# Send this message to the BBMD
57+
message.data_link.request_type = ::BACnet::Message::IPv4::Request::DistributeBroadcastToNetwork
58+
payload = DispatchProtocol.new
59+
payload.message = DispatchProtocol::MessageType::WRITE
60+
payload.ip_address = @bbmd_ip.address
61+
payload.id_or_port = @bbmd_ip.port.to_u64
62+
payload.data = message.to_slice
63+
transport.send payload.to_slice
64+
else
65+
server.send message, to: address
66+
end
67+
end
68+
@bacnet_client = client
69+
70+
# Track the discovery of devices
71+
registry = ::BACnet::Client::DeviceRegistry.new(client)
72+
registry.on_new_device { |device| new_device_found(device) }
73+
@device_registry = registry
74+
75+
spawn { process_data(server, client) }
76+
on_update
77+
end
78+
79+
# This is our input read loop, grabs the incoming data and pumps it to our client
80+
protected def process_data(server, client)
81+
loop do
82+
break if server.closed?
83+
bytes, client_addr = server.receive
84+
85+
begin
86+
message = IO::Memory.new(bytes).read_bytes(::BACnet::Message::IPv4)
87+
client.received message, client_addr
88+
@packets_processed += 1_u64
89+
rescue error
90+
logger.warn(exception: error) { "error parsing BACnet packet from #{client_addr}: #{bytes.to_slice.hexstring}" }
91+
end
92+
end
93+
end
94+
95+
def on_unload
96+
udp_server.close
97+
end
98+
99+
def on_update
100+
bbmd_ip = setting?(String, :bbmd_ip) || ""
101+
@bbmd_ip = Socket::IPAddress.new(bbmd_ip, 0xBAC0) if bbmd_ip.presence
102+
@verbose_debug = setting?(Bool, :verbose_debug) || false
103+
schedule.in(5.seconds) { query_known_devices }
104+
105+
perform_discovery if bbmd_ip.presence
106+
end
107+
108+
def packets_processed
109+
@packets_processed
110+
end
111+
112+
def connected
113+
bbmd_ip = setting?(String, :bbmd_ip)
114+
perform_discovery if bbmd_ip.presence
115+
end
116+
117+
def devices
118+
device_registry.devices.map do |device|
119+
{
120+
name: device.name,
121+
model_name: device.model_name,
122+
vendor_name: device.vendor_name,
123+
124+
ip_address: device.ip_address.to_s,
125+
network: device.network,
126+
address: device.address,
127+
id: device.object_ptr.instance_number,
128+
129+
objects: device.objects.map { |obj|
130+
value = begin
131+
val = obj.value.try &.value
132+
case val
133+
in ::BACnet::Time, ::BACnet::Date
134+
val.value
135+
in ::BACnet::BitString, BinData
136+
nil
137+
in ::BACnet::PropertyIdentifier
138+
val.property_type
139+
in ::BACnet::ObjectIdentifier
140+
{val.object_type, val.instance_number}
141+
in Nil, Bool, UInt64, Int64, Float32, Float64, String
142+
val
143+
end
144+
rescue
145+
nil
146+
end
147+
{
148+
name: obj.name,
149+
type: obj.object_type,
150+
id: obj.instance_id,
151+
152+
unit: obj.unit,
153+
value: value,
154+
seen: obj.changed,
155+
}
156+
},
157+
}
158+
end
159+
end
160+
161+
def query_known_devices
162+
devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress
163+
devices.each do |info|
164+
device_registry.inspect_device(info.address, info.identifier, info.net, info.addr)
165+
end
166+
"inspected #{devices.size} devices"
167+
end
168+
169+
def update_values(device_id : UInt32)
170+
if device = @devices[device_id]?
171+
client = bacnet_client
172+
@mutex.synchronize do
173+
device.objects.each &.sync_value(client)
174+
end
175+
"updated #{device.objects.size} values"
176+
else
177+
raise "device #{device_id} not found"
178+
end
179+
end
180+
181+
def perform_discovery : Nil
182+
bacnet_client.who_is
183+
end
184+
185+
alias ObjectType = ::BACnet::ObjectIdentifier::ObjectType
186+
187+
protected def get_object_details(device_id : UInt32, instance_id : UInt32, object_type : ObjectType)
188+
device = @devices[device_id]
189+
device.objects.find { |obj| obj.object_ptr.object_type == object_type && obj.object_ptr.instance_number == instance_id }.not_nil!
190+
end
191+
192+
def write_real(device_id : UInt32, instance_id : UInt32, value : Float32, object_type : ObjectType = ObjectType::AnalogValue)
193+
object = get_object_details(device_id, instance_id, object_type)
194+
bacnet_client.write_property(
195+
object.ip_address,
196+
::BACnet::ObjectIdentifier.new(object_type, instance_id),
197+
::BACnet::PropertyType::PresentValue,
198+
::BACnet::Object.new.set_value(value)
199+
)
200+
value
201+
end
202+
203+
def write_double(device_id : UInt32, instance_id : UInt32, value : Float64, object_type : ObjectType = ObjectType::LargeAnalogValue)
204+
object = get_object_details(device_id, instance_id, object_type)
205+
bacnet_client.write_property(
206+
object.ip_address,
207+
::BACnet::ObjectIdentifier.new(object_type, instance_id),
208+
::BACnet::PropertyType::PresentValue,
209+
::BACnet::Object.new.set_value(value)
210+
)
211+
value
212+
end
213+
214+
def write_unsigned_int(device_id : UInt32, instance_id : UInt32, value : UInt64, object_type : ObjectType = ObjectType::PositiveIntegerValue)
215+
object = get_object_details(device_id, instance_id, object_type)
216+
bacnet_client.write_property(
217+
object.ip_address,
218+
::BACnet::ObjectIdentifier.new(object_type, instance_id),
219+
::BACnet::PropertyType::PresentValue,
220+
::BACnet::Object.new.set_value(value)
221+
)
222+
value
223+
end
224+
225+
def write_signed_int(device_id : UInt32, instance_id : UInt32, value : Int64, object_type : ObjectType = ObjectType::IntegerValue)
226+
object = get_object_details(device_id, instance_id, object_type)
227+
bacnet_client.write_property(
228+
object.ip_address,
229+
::BACnet::ObjectIdentifier.new(object_type, instance_id),
230+
::BACnet::PropertyType::PresentValue,
231+
::BACnet::Object.new.set_value(value)
232+
)
233+
value
234+
end
235+
236+
def write_string(device_id : UInt32, instance_id : UInt32, value : String, object_type : ObjectType = ObjectType::CharacterStringValue)
237+
object = get_object_details(device_id, instance_id, object_type)
238+
bacnet_client.write_property(
239+
object.ip_address,
240+
::BACnet::ObjectIdentifier.new(object_type, instance_id),
241+
::BACnet::PropertyType::PresentValue,
242+
::BACnet::Object.new.set_value(value)
243+
)
244+
value
245+
end
246+
247+
protected def new_device_found(device)
248+
logger.debug { "new device found: #{device.name}, #{device.model_name} (#{device.vendor_name}) with #{device.objects.size} objects" }
249+
logger.debug { device.inspect } if @verbose_debug
250+
251+
@devices[device.object_ptr.instance_number] = device
252+
end
253+
254+
def received(data, task)
255+
# we should only be receiving broadcasted messages here
256+
protocol = IO::Memory.new(data).read_bytes(DispatchProtocol)
257+
258+
logger.debug { "received message: #{protocol.message} #{protocol.ip_address}:#{protocol.id_or_port} (size #{protocol.data_size})" }
259+
260+
if protocol.message.received?
261+
message = IO::Memory.new(protocol.data).read_bytes(::BACnet::Message::IPv4)
262+
bacnet_client.received message, @bbmd_ip
263+
end
264+
265+
task.try &.success
266+
end
267+
end

drivers/ashrae/bacnet_models.cr

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
require "bacnet"
2+
require "json"
3+
4+
module Ashrae
5+
class DeviceAddress
6+
include JSON::Serializable
7+
8+
def initialize(@ip, @id, @net, @addr, @name, @model_name, @vendor_name)
9+
end
10+
11+
getter ip : String
12+
getter id : UInt32
13+
getter net : UInt16?
14+
getter addr : String?
15+
16+
def address
17+
Socket::IPAddress.new(@ip, 0xBAC0)
18+
end
19+
20+
def identifier
21+
::BACnet::ObjectIdentifier.new :device, @id
22+
end
23+
end
24+
25+
class DispatchProtocol < BinData
26+
endian big
27+
28+
enum MessageType
29+
OPENED
30+
CLOSED
31+
RECEIVED
32+
WRITE
33+
CLOSE
34+
end
35+
36+
enum_field UInt8, message : MessageType = MessageType::RECEIVED
37+
string :ip_address
38+
uint64 :id_or_port
39+
uint32 :data_size, value: ->{ data.size }
40+
bytes :data, length: ->{ data_size }, default: Bytes.new(0)
41+
end
42+
end

drivers/ashrae/bacnet_spec.cr

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require "placeos-driver/spec"
2+
3+
# NOTE:: this spec only works if there is a BACnet network configured locally
4+
# such as https://github.com/chipkin/BACnetServerExampleCPP/releases
5+
DriverSpecs.mock_driver "Ashrae::BACnet" do
6+
exec(:query_known_devices).get
7+
(exec(:devices).get.not_nil!.size > 0).should be_true
8+
end

shard.lock

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ shards:
77

88
action-controller:
99
git: https://github.com/spider-gazelle/action-controller.git
10-
version: 4.5.0
10+
version: 4.5.1
1111

1212
active-model:
1313
git: https://github.com/spider-gazelle/active-model.git
@@ -17,9 +17,13 @@ shards:
1717
git: https://github.com/taylorfinnell/awscr-signer.git
1818
version: 0.8.2
1919

20+
bacnet:
21+
git: https://github.com/spider-gazelle/crystal-bacnet.git
22+
version: 0.10.3
23+
2024
bindata:
2125
git: https://github.com/spider-gazelle/bindata.git
22-
version: 1.9.0
26+
version: 1.9.1
2327

2428
connect-proxy:
2529
git: https://github.com/spider-gazelle/connect-proxy.git
@@ -147,7 +151,7 @@ shards:
147151

148152
placeos:
149153
git: https://github.com/placeos/crystal-client.git
150-
version: 2.4.1
154+
version: 2.4.3
151155

152156
placeos-compiler:
153157
git: https://github.com/placeos/compiler.git
@@ -163,7 +167,7 @@ shards:
163167

164168
placeos-models:
165169
git: https://github.com/placeos/models.git
166-
version: 5.7.5
170+
version: 5.8.0
167171

168172
pool:
169173
git: https://github.com/ysbaddaden/pool.git
@@ -179,7 +183,7 @@ shards:
179183

180184
redis:
181185
git: https://github.com/stefanwille/crystal-redis.git
182-
version: 2.8.0
186+
version: 2.8.1
183187

184188
redis-cluster:
185189
git: https://github.com/caspiano/redis-cluster.cr.git

shard.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,7 @@ dependencies:
8282
awscr-signer:
8383
github: taylorfinnell/awscr-signer
8484
version: ~> 0.8
85+
86+
bacnet:
87+
github: spider-gazelle/crystal-bacnet
88+
version: ~> 0.10

0 commit comments

Comments
 (0)