Warning
The main branch of this repository contains work-in-progress development code for the upcoming release, and is not guaranteed to be stable or working. It is only compatible with the main branch of edgex-compose which uses the Docker images built from the main branch of this repo and other repos.The source for the latest release can be found at Releases.
EdgeX device service for communicating with LLRP-based RFID readers. This service provides the capabilities to configure and enable LLRP-based RFID readers to generate asynchronous EdgeX readings that contain LLRP ROAccessReport and Reader messages. A ROAccessReport can be used to examine data read from one or more RFID tags seen by the reader over a given time period, and a ReaderEventNotifications message can be used for data regarding device connection changes, such as attempted connections, connections closed, and errors in a connection.
The LLRP RFID Inventory Service can be used to automatically configure this service and readers it manages. This repository also provides a higher-level abstraction for working with RFID tag by parsing ROAccessReports and generating higher-level tag-specific readings (e.g. TAG_APPEARED, TAG_MOVED, etc).
Build Native
make build
Build Docker
make docker
Run EdgeX Jakarta
Run device-rfid-llrp
Docker
make gen ds-llrp no-secty
make gen ds-llrp
docker-compose -p edgex up -d
Native
cd cmd && EDGEX_SECURITY_SECRET_STORE=false ./device-rfid-llrp-go -cp -r
Configure subnet information
Note 1: This script requires EdgeX and device-rfid-llrp to be running first.
Note 2: This step is optional if you already configured the subnets beforehand in the configuration.yaml file.
./bin/auto-configure.sh
Trigger a device discovery
Note 1: Make sure your LLRP devices are plugged in and powered on before this step
Note 2: The system will trigger a discovery 10 seconds after any change is made to the subnet or discover port config, so this step is often not required
curl -X POST http://localhost:59989/api/v2/discovery
At this point the device-rfid-llrp
service should have discovered your LLRP devices on the network and registered them with EdgeX.
For more detailed info, see Device Discovery and EdgeX Device Naming.
Note: Device discovery is currently only compatible with IPv4 networks. If using an IPv6-only network, you will need to manually add your devices to EdgeX.
This service has the functionality to probe the local network in an effort to discover devices that support LLRP.
This discovery also happens at a regular interval and can be configured via EdgeX Consul for existing installations, and configuration.yaml for default values.
The additional discovery configuration can be modified via the [AppCustom]
section of the configuration.yaml file.
Note: Please read the Notes on configuration.yaml for things to be aware of when modifying this file.
AppCustom:
# List of IPv4 subnets to perform LLRP discovery process on, in CIDR format (X.X.X.X/Y)
# separated by commas ex: "192.168.1.0/24,10.0.0.0/24"
DiscoverySubnets: ""
# Maximum simultaneous network probes
ProbeAsyncLimit: 4000
# Maximum amount of seconds to wait for each IP probe before timing out.
# This will also be the minimum time the discovery process can take.
ProbeTimeoutSeconds: 2
# Port to scan for LLRP devices on
ScanPort: "5084"
# Maximum amount of seconds the discovery process is allowed to run before it will be cancelled.
# It is especially important to have this configured in the case of larger subnets such as /16 and /8
MaxDiscoverDurationSeconds: 300
The DiscoverySubnets
config option defaults to blank, and needs to be provided before a discovery can occur.
The easiest way of doing this is via the following script:
./bin/auto-configure.sh
This script requires access to Consul. If running with security enabled, then a Consul token is required. In that case it should be passed as an argument to the script, i.e:
./bin/auto-configure.sh a7910d82-69ae-ea21-214d-fd1326e68545
What this command does is check your local machine's network interfaces to see which ones are both online
and a physical device (instead of virtual). It uses that information to fill in the DiscoverySubnets
field in Consul for you.
Note: Whenever a change to
DiscoverySubnets
orScanPort
is detected via a Consul watcher, a discovery is automatically triggered after a 10-second debounced delay.
Discovery can be manually triggered via REST:
# POST http://<hostname>:<device-rfid-llrp-go port>/api/v2/discovery
curl -X POST http://localhost:59989/api/v2/discovery
Every IP address in each of the subnets provided in DiscoverySubnets
are probed at the specified ScanPort
(default 5084
).
If a device returns LLRP response messages, a new EdgeX device is created.
EdgeX device names are generated from information it receives from the LLRP device. In the case of Impinj readers, this device name should match the device's hostname given by Impinj, however the hostname information is not available through LLRP, so the generated name may differ in certain edge cases.
The device names are generated using the following naming format:
<Prefix>-<ID>
<Prefix>
is generated based on the Vendor and Model of the LLRP device.
If the device is a model with a known naming scheme such as most Impinj readers,
the prefix will be set accordingly, otherwise it will default to LLRP
.
<ID>
field is based on the LLRP value GetReaderConfigResponse.Identification.ReaderID
and can be one of two things.
If the LLRP device returns a MAC address (ID_MAC_EUI64
)
for the GetReaderConfigResponse.Identification.IDType
field, the last 3 octets
of the mac address will be used in the following format: XX-XX-XX
.
So given the following MAC address 00:ef:16:19:fe:16
, the <ID>
portion of
the device name would be 19-FE-16
.
If the LLRP device returns an EPC (ID_EPC
)
for the GetReaderConfigResponse.Identification.IDType
field, the entire
value of the GetReaderConfigResponse.Identification.ReaderID
field
is converted into lowercase hexadecimal and used as the <ID>
. Example: LLRP-12fec5432453df3ac
SpeedwayR-19-FE-16
xSpan-19-FE-16
xArray-19-FE-16
LLRP-19-FE-16
LLRP-12fec5432453df3ac
You can add devices directly via EdgeX's APIs or via the yaml configuration, as in the following example:
Note: Please read the Notes on configuration.yaml for things to be aware of when modifying this file.
DeviceList:
Name: "Speedway"
Profile: "Device-LLRP-Profile"
Description: "LLRP RFID Reader"
Labels: ["LLRP", "RFID"]
Protocols:
tcp:
host: "192.168.86.88"
port: "5084"
For some use cases, you may want or need to supply your own deviceProfile
,
but most LLRP
operations are available via the included profile.
The section below details how deviceResources
and deviceCommands
are mapped to LLRP
Messages and Parameters, but first,
here's what you can and can't do with the default profile:
If a Reader returns a response with an LLRPStatusCode
other than Success
(including ERROR_MESSAGE
, Message Type 100),
then the service decodes any contained ParameterError
s or FieldError
s
and returns them as an error with the LLRPStatus
's ErrorDescription
.
The only LLRP
Message that you can send with a custom profile
but can't send with the default profile is the CustomMessage
(Message Type 1023).
As noted above, you can send CustomParameter
s (Parameter Type 1023)
using the default profile when writing Configuration or ROSpecs/AccessSpecs.
Other than that, you may find it useful to create a custom profile
to bundle multiple read requests, supplying default,
or mapping special names to ROSpec
s.
The following LLRP
operations are not supported at this time:
RequestedData
field
nor to append CustomParameter
s in the request;
this service always requests All
data from the Reader.GetReport
(Message Type 60),
which means you should not configure ROReportSpec
s with a NULL trigger.EnableEventsAndReports
(Message Type 64),
so you should not set EventsAndReports
to true
in the ReaderConfiguration
.CloseConnection
(Message Type 14)KeepAliveAck
(Message Type 72)GetSupportedVersion
(Message Type 46)SetProtocolVersion
(Message Type 47)ClientRequestOpResponse
,
so it's not useful to configure a ClientRequestOpSpec
,
though we don't explicitly prevent you from doing so.
There's really no reasonable general-purpose way to support it
except through code, so if you need it, consider forking this repo
and adding the interaction by using our LLRP Library.A ProvisionWatcher
is used during the device discovery process to match discovered readers.
It also determines the device profile to be used when adding the new device. The two pre-defined
watchers are a generic one, and one for Impinj. Any new provision watchers
added to the same directory will be auto-imported on service startup.
EdgeX requires that deviceResources
are representable as a basic type,
a homogeneous array of a basic type, or a CBOR "binary" type.
Because LLRP
Messages and Parameters are highly structured,
this device service maps Specs, Configuration, Capabilities, and Notifications
to and from JSON-encoded string using Go
's json
package
and the structures defined in our LLRP Library.
When a Parameter or Message includes arbitrary data
(e.g., the contents of a tag's EPC
memory bank or a CustomParameter
payload),
they're represented in Go
as a []byte
, which Go
marshals and unmarshals
as strings representing the base64-encoded data.
For requests to read a deviceResource
(i.e., a GET
request),
the service determines which LLRP
message to send based upon the resource name.
It marshals the result to JSON and returns it as a string EdgeX Reading
.
LLRP
constants are encoded according to the LLRP
spec
(e.g., the StopTriggerType
of an AISpec
is returned as 0, 1, or 2).
The service uses only the resource name and ignores any attributes it may have;
custom LLRP parameter extensions are not supported for resources read requests,
nor is the LLRP CustomMessage
(Message Type 1023).
The following list details the resource names the service recognizes and how it satisfies the read request:
ReaderCapabilities
sends GET_READER_CAPABILITIES
(Message Type 1)
with RequestedData: All
.
It returns the resulting GET_READER_CAPABILITIES_RESPONSE
(Message Type 12).ReaderConfig
sends GET_READER_CONFIG
(Message Type 2)
with RequestedData: All
, and AntennaID
, GPIPort
, and GPOPort
set to 0.
It returns the resulting GET_READER_CONFIG_RESPONSE
(Message Type 12).ROSpec
sends GET_ROSPECS
(Message Type 26)
and returns GET_ROSPECS_RESPONSE
(Message Type 36).AccessSpec
sends GET_ACCESSSPECS
(Message Type 44)
and returns GET_ACCESSSPECS_RESPONSE
(Message Type 44).You can configure deviceCommands
in your device profile
to read more than one resource at a time,
in which case the device service attempts each of the commands requests in the order
specified by the device profile and returns the results of all of them.
For LLRP
messages that require only an ROSpecID
or AccessSpecID
,
we define them as operations upon uint32
deviceResource
s with those names.
(note that EdgeX requires passing these as strings when calling deviceCommand
s)
To disambiguate the desired write operation,
deviceCommands
that use them must specify them as the first resource to set
,
and must include a "pseudo-resource" called Action
,
a string which must be one of "Enable", "Disable", "Start", "Stop", or "Delete".
Note that it is not possible in LLRP
to start or stop an AccessSpec
,
so those only apply to ROSpec
s.
Because we must use this pseudo-resource to know what Action to take,
It is not possible to write more than one deviceResource
at a time.
To add an ROSpec
or AccessSpec
, or to set the ReaderConfig
,
you can use deviceCommands
that write a deviceResource
of the same name
When you PUT
a new instance of these resource types,
the service attempts to unmarshal the resource's parameter string
according to its name into the appropriate
LLRP
message structure defined in our LLRP library.
Unlike read requests, the service handles write requests on deviceResources
with names other than those defined above.
It assumes these resources are accessible via CustomMessage
(Message Type 1023),
and looks for vendor
and subtype
attributes on the resource,
which it inserts into the relevant fields of the LLRP
message.
Although in LLRP
these are a uint32
and uint8
(respectively),
note that EdgeX requires all attributes values are passed as strings.
Assuming these are present, the service interprets the parameter string
as a base64-encoded byte array, which it uses as the payload
of the CustomMessage
.
You can see an example device profile
that defines a deviceResource
to enable Impinj's custom extensions.
After an LLRP device is added, either via discovery or directly through EdgeX,
the driver works to maintain a connection to it and monitor its health.
When it detects an unhealthy connection, it closes it and redials the Reader.
If it fails to connect two times consecutively,
it sets the device's OperationState
to DISABLED
,
but continues to attempt to restore the connection indefinitely.
It'll reattempt the connection using exponential backoff with jitter,
capped to a max of 30 mins between attempts.
If its IP address changes (either manually or via Discovery),
the service attempts to connect to it at the new address.
The device service sets the device to DISABLED
in EdgeX
as soon as it thinks it's disabled, but exactly how long this takes
depends on the conditions leading to failure.
Nevertheless, a disconnected device should appear DISABLED
within about 2 minutes.
The device service sets a 60s
timeout when reading from OS's TCP connection.
To ensures that a healthy connection will not timeout,
it configures Readers to send KeepAlive
messages every 30s
.
Because it uses this to monitor the connection health,
it overrides the KeepAliveSpec
in SetReaderConfig
requests with its own.
These timeout values are not configurable, but they are easy to change when building the service by changing this code.
There are a couple of example scripts here to interact with devices through EdgeX's APIs. They aren't meant to be perfect or necessarily the best way to do things, but they should help give examples of what's possible. They don't do much error handling, so don't rely on them for much more than happy-path testing.
ROSpec
to the first reader that EdgeX knows about,
waits a bit, disables/deletes it from the reader,
then displays any collected tags.They assume everything is running and expect you have a these on your path:
jq
, curl
, sed
, xargs
, base64
, and od
.
By default, they all try to connect to localhost
on the typical EdgeX ports.
command.sh
and data.sh
take args/options; use --help
to see their usage.
example.sh
uses a couple of variables defined at the top of the file
to determine which host/port/file to use.
The command script in particular shows some examples in its usage
.
You can use it to control arbitrary LLRP configuration,
such as adding/modifying/removing ROSpec
s and AccessSpec
s,
changing a Reader's default ROAccessReport
reported data,
enabling/modifying/disabling KeepAlive
messages,
and enabling/disabling specific ReaderEventNotification
s.
There are many unit tests available to run with the typical go
tools.
make test
executes go test ./... -coverprofile=coverage.out
and so can be used to quickly run all tests and generate a coverage report.
There are some tests in the internal/llrp
package
which expect access to a reader.
By default, they're skipped.
To run them, supply a -reader=<host>:<port>
argument to Go's test tool.
For example, from the internal/llrp
directory,
you can run go test -reader=192.0.2.1:5084
;
assuming an LLRP device is reachable at that IP and port,
it will connect to it, get its config and capabilities,
then try to send and enable/start a basic ROSpec
.
It waits a short time, possibly collecting ROAccessReport
s
(assuming tags are in your reader's antennas' FoVs),
and the disables/deletes the ROSpec
.
The full options it will respond to:
short
skips the ROSpec
test described above,
since it takes a little while to wait for the reports.verbose
logs some extra marshaling/unmarshaling data.reader
sets an address of an LLRP device and runs functional tests against it.ro-access-dir
uses a different subdirectory of
internal/llrp/testdata
(by default, roAccessReports
)
when running TestClient_withRecordedData
.
See below for more info.update
is used in the context of functional tests,
but is only needed in special circumstances
and should not be used unless you understand the consequences.Note that if you're using the Goland IDE,
you can put these options in a test config's program arguments
,
though the short
and verbose
options need the test.
prefix.
The internal/llrp/testdata
folder contains a series of .json
and .bytes
files.
They're used by the TestClient_withRecordedData
unit test
which uses them roughly as follows:
.json
-> struct 1
-> new bytes
.bytes
-> struct 2
-> new JSON
.json
and new JSON
.bytes
and new bytes
The test only passes if the unmarshaling/comparisons are successful,
which assumes it's able to match the name to an LLRP message type
(it'll print an error if it doesn't have a match).
Files in that directory only need to match the format
{LLRP Message Type}-{3 digits}.{json|bytes}
;
other file names are ignored.
The message name must also be specified in the test's switch
,
or it'll show an error about not finding a matching message type.
The test runs the same process using files in roAccessReports
subdirectory,
which just makes it a little easier to organize those files.
You can use a different test directory with different reports
by using the ro-access-dir
flag while running that test,
but the alternative directory must be a subdirectory of testdata
.
For example, to run the test with files in a testdata/giantReports
directory:
go test -v -run ^TestClient_withRecordedData$ -ro-access-dir=giantReports
By default, this directory is set to roAccessReports
.
If you set it to ""
, it'll skip checking it entirely.
There's actually nothing special about the directory name nor this flag
that requires its contents be ROAccessReport
message specifically,
so you're free to segment other json/binary message file pairs
into various directories and rerun the test with appropriate flag values.
As long as the filenames match the pattern described above
and that name is in the switch
block of the compareMessages
function
of the test it'll test them.
As the test name implies, the .bytes
data is recorded from an actual reader.
It's possible to use the included Functional tests
to record new data by passing the -update
flag.
Under most circumstances, this isn't necessary
(some cases where it is are described below).
When -update
is true
, the TestClientFunctional
test
skips its normal tests and instead runs the collectData
function.
That function sends a series of messages to the -reader
and records the binary results in the testdata
directory,
overwriting existing ones if present (they're version-controlled for a reason).
Most messages are only sent once, hence they'll end in -000
.
When listening for ROSpecs
, on the other hand, it'll write as many as it collects.
The .gitignore
is configured to ignore most of them,
but it can be handy for testing.
At present, the recorder ignores the ro-access-dir
flag described above,
and writes the output directly to the testdata
directory;
the data tests will happily handle them in testdata
,
so this can still be fine for testing,
but in the future, it'd probably be better for it to make use of that flag.
So when is this flag useful?
Basically, if the marshaling/unmarshaling code changes
in a way that results in different JSON/binary interpretation or output.
For the binary side, the format should be fixed,
as its subject to the LLRP specification.
Furthermore, even if the unmarshaling code is wrong,
if the llrp.Client
code is correctly handling the message boundaries,
the binary
/.bytes
files should not have reason to change.
On the other hand, the JSON format is not specific to LLRP.
If the names of keys used in the JSON formats change,
these tests most likely will no longer pass.
For instance, it's possible to implement
json.(Unm|M)arshaler
/encoding.Text(Unm|M)arshaler
interfaces
to make some LLRP values easier to read.
Doing so may break the JSON -> Go struct
conversion,
which will result in zero values when going Go struct -> binary
,
which will change the output.
In the other direction, the binary -> Go struct
conversion should be unaffected,
but Go struct -> JSON
should differ from the recorded value.
The test outputs the location of "first difference" along with some surrounding context
to aid in correcting these tests.
For just name changes, it's probably better to make the changes to the JSON by hand.
However, if the change is large enough,
it could be easier to just throw away the existing folder and repopulate it.
That is when the -update
flag makes sense:
go test -run ^TestClientFunctional$ -reader="$READER_ADDR" -update
There is a test helper file with some objects/methods that may be useful when developing unit tests. The following are particularly useful:
go doc llrp.TestDevice
go doc llrp.GetFunctionalClient
configuration.yaml
file will require you to rebuild the docker image
and re-deploy using the new imageconfiguration.yaml
file will be ignored if this is not
the first time you have deployed the service and EdgeX Consul is enabled.