The master¶
py1815.master is a DNP3 master, the side that asks. It is here to exercise
outstations: this library's, in tests, and any other, on a bench. This page is
the master from Python; the console and the API
are the same master from a browser and from another process.
A first version
It polls by class, reads named points, confirms what asks to be confirmed, takes unsolicited responses and keeps the last value of every point. It operates outputs, sets the clock by a plain write or by either of the standard's procedures, clears the restart indication, freezes counters and sends broadcasts. Left alone it looks after an outstation as a master does: settles it on connecting, fetches the events it says it has, and connects again when the connection is lost. It speaks to an IEEE 1815.2 DER in the profile's terms, and connects over TCP or TLS. All of it from Python, with or without an event loop, from a JSON service, or from a web console. The plan says what follows.
Over a socket¶
import asyncio
from py1815.master import ALL, Master, PointType
async def main() -> None:
async with Master() as master:
lab = await master.add(
"lab", host="192.0.2.10", port=20000, outstation_address=1024, master_address=1
)
await lab.idle() # what it does on connecting is done
print(len(lab.store.points(PointType.ANALOG_INPUT)), "analog inputs read")
poll = await lab.integrity_poll()
print(poll.outcome, len(poll.fragments), "fragments", len(poll.objects), "objects")
answer = await lab.read(analog_inputs=[4, 6, 8], binary_inputs=ALL)
for decoded in answer.objects:
print(decoded.point.value, decoded.index, decoded.value, decoded.flags)
power = lab.store.analog_input(4)
print(power.value, power.flags, power.from_event)
asyncio.run(main())
Master holds outstations by the name you give each. add connects and
returns an Outstation, which is where the requests are:
| Request | Asks for |
|---|---|
integrity_poll() |
Classes 1, 2 and 3, then class 0: every event, then every static value |
scan(kind) |
"integrity", "events", one class: "class0" to "class3", or "outputs" |
read(...) |
Named points, by type and index, or ALL of a type, in one request |
operate(...) |
Outputs commanded, by type and index. See Commanding outputs |
write_time(ms) |
The outstation's clock, in milliseconds since the epoch, or now |
synchronize_time(procedure) |
The outstation's clock, by the "lan" or "non_lan" procedure. See Setting the clock |
clear_restart() |
The restart indication, cleared |
freeze(clear=, respond=) |
Every counter frozen, and cleared as it is if asked |
restart(kind) |
A "cold" or a "warm" restart |
request(function, body) |
Any function code, with the octets that follow it |
broadcast(function, body, address=) |
Any function code, sent to a broadcast address. See Broadcast |
Requests made at the same time take turns, in the order they were made. An outstation carries one request at a time.
read(...) sends each run of consecutive indices as one start-stop range, in
ascending order, because that is the form every outstation accepts. The points
come back in ascending order, whatever order you named them in.
An integrity poll does not return what the outputs stand at. The IEEE 1815.2
profile leaves output status out of class 0, so it is read by naming its
groups: scan("outputs") reads binary and analog output status, and
read(analog_outputs=ALL) reads one of them.
What comes back¶
Every request returns an Exchange that says what happened. Nothing about the
outstation's answer is an exception.
| Field | Holds |
|---|---|
outcome |
COMPLETE, TIMEOUT, SENT for a request that takes no response, or ABANDONED when the connection ended first or the caller stopped waiting |
fragments |
Each response fragment, in the order received |
objects |
Every object of every fragment, decoded, in the order sent |
iin |
The indications of the last fragment, or None if nothing arrived |
undecoded |
Where an object could not be read: why, and the octets from there on |
request, sequence, elapsed |
The fragment that was sent, its sequence number, and how long the exchange took |
A timeout is a result, and so is a refusal:
answer = await lab.read(analog_inputs=[60000])
if answer.iin.is_set(IIN2Bit.PARAM_ERROR):
print("the outstation has no such point")
An exception means the interface was misused or there was no connection:
NotConnected for a request with no connection, OSError from add or
connect when the connection cannot be made, ValueError for a request that
names nothing.
A read that is not answered¶
A read that times out with nothing received can be sent again, as IEEE 1815-2012 4.3 rule 16 has it: under the same sequence number, with the same octets, so an outstation that did answer the first is answering both.
lab = await master.add("lab", host="192.0.2.10", read_retries=2)
poll = await lab.integrity_poll()
print(poll.retries) # how many times it was sent again
None by default, as the DNP Users Group recommends for a master's application
layer: a retry hides that the outstation did not answer, which is what a test
of an outstation wants to see. Only a read is ever sent again. A control, a
write, a freeze, a restart, a time synchronization and a broadcast are each
sent once, whatever read_retries says, and a read whose answer has begun to
arrive is not sent again.
A decoded object carries its group, variation and index, its value, its flag
octet, and its time when the outstation gave one. flags is None for a
variation that has no flags, which is not the same as none being set. An
object the library does not know ends the reading of that fragment: what came
before it is kept, and undecoded holds the reason and the rest of the octets.
What it does without being asked¶
A master does a few things of its own accord, and this one does them unless told not to. Each is a task:
| Task | Does | When | Default |
|---|---|---|---|
startup |
Reads everything with an integrity poll. On connecting, a master that names classes in enable_unsolicited first asks the outstation to stop unsolicited reporting |
On connecting, and when the outstation reports that it restarted | On |
clear_restart |
Clears the restart indication | When a response carries it | On |
write_time |
Sets the outstation's clock | When a response asks for the time | On |
enable_unsolicited |
Asks the outstation to report the classes named without being polled. With no classes the master does not use unsolicited reporting, and sends no request to enable or disable it (IEEE 1815-2012 5.1.1.2) | After startup | Off: no classes |
events_when_indicated |
Polls for events | When a response says some are waiting | On |
integrity_on_overflow |
Reads everything again | When a response says the event buffer overflowed | On |
event_follow_ups |
Polls for events again, at once | When a poll's own response still says events are waiting, up to this many times in a row | 3 |
from py1815.master import Tasks
lab = await master.add(
"lab",
host="192.0.2.10",
tasks=Tasks(enable_unsolicited=(1, 2, 3), write_time=False),
)
await lab.idle()
lab.tasks = Tasks(events_when_indicated=False) # changed while connected
write_time writes the master's time as it stands unless
time_procedure names one of the standard's procedures, "lan" or
"non_lan": see Setting the clock.
Tasks can be changed at any time by setting tasks. What is done next
follows the new settings, and a task that was due and is now off is not
done; a startup sequence under way finishes.
add and connect return as soon as the connection is made. What is done on
connecting is done next, before anything you ask for and before any scan on a
schedule, and idle() waits for it. When several tasks are due they are done
in the order of the table, with nothing else between them.
Every request a task makes is an exchange like any other: it is in the trace,
it is counted, it is handed to on_exchange, and its task says which task
made it. An exchange you asked for has task set to None.
Three things a task never does:
- Command an output. No task selects or operates anything, and none sends a request between a select and its operate.
- Answer itself without end. A task is not made due by the response to
its own request, with one bounded exception: a poll whose response still
says events are waiting is followed by another, at most
event_follow_upstimes in a row, and then not again until a poll finds nothing waiting. An outstation that never clears an indication costs a few requests, never a stream. - Repeat a scan. Nothing is sent on a schedule unless you ask for it with
repeat_scan.
A connection that is lost¶
A connection that was made and then lost is made again: the master tries every five seconds until it succeeds, and then does what it does on connecting, so the store is read afresh from an outstation that may have restarted.
lab = await master.add("lab", host="192.0.2.10", reconnect=1.0) # every second
lab = await master.add("lab", host="192.0.2.10", reconnect=None) # never
A connection you closed with close() is not made again.
A first connection that cannot be made raises OSError at once, unless you
ask the master to wait for the outstation:
lab = await master.add("lab", host="192.0.2.10", wait=60) # try for a minute
await lab.connect(wait=60, every=2.0) # every two seconds
It tries once a second unless told otherwise, and raises the last error when the time is up.
on_connection is called with True when the connection is made and False
when it is lost. For the endpoints and the reason as well, including each
attempt that failed, give a connection_observer; see
Connection and anomaly reports.
Over TLS¶
Give the outstation a TLS context, and the connection is made over TLS:
from py1815.master.tls import TlsSettings
tls = TlsSettings(
ca="lab-ca.pem", # what the outstation's certificate is checked against
certificate="master.pem", # what the master offers
key="master.key",
server_name="inverter.lab", # when the certificate does not name the host
)
lab = await master.add(
"lab", host="192.0.2.10", port=20000, tls=tls.context(), server_name=tls.server_name
)
Any ssl.SSLContext will do. An outstation that listens with TLS usually
requires a certificate of the master, as this library's does. A key that is
encrypted is opened with the password in the PY1815_MASTER_KEY_PASSWORD
environment variable. A certificate that does not check out is an OSError
from add or connect.
The store¶
Each outstation has a store: the last value of every point it has reported,
whichever request or unsolicited response brought it.
lab.store.analog_input(4) # one point, or None if never reported
lab.store.points(PointType.BINARY_INPUT) # every binary input, by index
lab.store.events # the events received, oldest first
A stored value says whether it came from an event (from_event), the time the
outstation gave it (time_ms), and when it was received.
Waiting for something¶
wait_for returns when a condition holds, and says whether it did:
held = await lab.wait_for(lambda store: store.analog_output(87).value == 5000, timeout=10)
held = await lab.wait_for(lambda _: lab.indications.is_set(IINBit.NEED_TIME), timeout=10)
The condition is looked at at once, and again each time a response or an
unsolicited response has been stored, and when the connection is made or ends.
Nothing is sent to make it hold. lab.indications is the indications of the
last response of either kind. A timeout returns False; it is not an
exception.
Unsolicited responses¶
An outstation built to send them does so whenever it has something to report.
The master confirms each one, puts its values in the store, keeps it in
lab.unsolicited, and calls a handler if you gave one:
The master enables them when it is told which classes to enable, after it has settled the outstation, and again after each restart and each reconnection:
Or once, by asking:
Commanding outputs¶
result = await lab.operate(
binary_outputs={17: True}, # a latch on
analog_outputs={88: 500}, # a setpoint
mode="select",
)
print(result.accepted) # True, False, or None
for status in result.statuses:
print(status.command.point.value, status.command.index, status.status)
A binary output is given True or False for a latch on or off, or an
operation by name: "pulse_on", "pulse_off", "trip", "close". An analog
output is given a number. Binary outputs are sent first, and each in the order
given, all in one request.
mode |
Sends |
|---|---|
"direct" |
A direct operate. The outstation answers with a status for each control |
"select" |
A select, and then an operate, with nothing allowed between them |
"direct_no_ack" |
A direct operate the outstation does not answer |
After a select, the operate is sent only if the outstation echoed every control back unchanged and accepted each one. One refusal stops them all.
The result says what happened and does not raise for it:
result.exchanges |
Each request made: one, or the select and the operate |
result.statuses |
For each control, the status the outstation answered with, and whether it echoed the control as sent |
result.operated |
Whether a request that operates was sent at all. False after a select that was refused |
result.accepted |
True when the outstation accepted every control, False when it refused one, and None when it never said |
accepted is None for a request that takes no acknowledgment, and for one
whose response did not arrive. That is not a refusal: the output may have been
operated. Nothing is ever sent twice. Whether to try again is yours to
decide, because only you know whether operating twice is harmless.
A whole number is sent as an integer, in 16 bits when it fits and 32 when it
does not, and anything else as a float. variation= says which outright.
The difference matters to an outstation that scales its points, as an
IEEE 1815.2 DER does: an integer is the value as transmitted, so 500 on a point
in tenths of a percent is 50 percent, and a float is the engineering value.
What cannot be carried is refused with a ValueError before anything is sent:
an output that is not a number, a value that does not fit the variation named,
an operation that is not one.
Setting the clock¶
write_time() sends the master's clock as it reads when the request is
built, and the outstation is behind by however long the request took to
arrive. IEEE 1815-2012 10.3.3 gives two procedures that correct for that, and
synchronize_time follows either:
done = await lab.synchronize_time("lan")
print(done.written, done.accepted, done.time_ms)
done = await lab.synchronize_time("non_lan")
print(done.delay_ms)
| Procedure | Sends |
|---|---|
"lan" |
A request to record the current time, then a write of the time the master sent it (group 50 variation 3). The outstation adds what has passed since it arrived. The default, since the master speaks TCP |
"non_lan" |
A delay measurement, then a write of the master's time plus half the round trip less the time the outstation says it held the request (group 50 variation 1) |
The write is sent only if the first request was answered without an error
indication; written says whether it was, and accepted whether the
outstation took it. Neither request is ever sent again (10.3.4). The master
takes its times when it builds each request, which is as close to the wire as
a program above the operating system's sockets gets.
The write_time task can use either procedure:
Tasks(time_procedure="lan").
Broadcast¶
A request sent to a broadcast address reaches every outstation on the link, and none answers it:
from py1815.master import requests
sent = await lab.broadcast(FunctionCode.IMMED_FREEZE_NR, requests.freeze_counters())
sent = await lab.broadcast(
FunctionCode.WRITE, requests.write_time(now_ms), address="shall_confirm"
)
address |
Is | What the outstation does about the response that reports it |
|---|---|---|
"no_confirm" |
0xFFFD | Does not ask for it to be confirmed |
"shall_confirm" |
0xFFFE | Asks for it to be confirmed |
"optional_confirm" |
0xFFFF | Either. The default, since every outstation takes it |
The exchange ends as soon as the request is sent, with outcome SENT and
broadcast naming the address. The outstation says it received one with
IIN1.0 in its next response to this master. Over TCP a broadcast reaches the
one outstation at the other end of the connection, which acts on it as a
broadcast.
An IEEE 1815.2 DER¶
Der puts the profile's point map in front of an outstation, so a script names
points as the profile does and reads and writes them in engineering units:
from py1815.master.profile import Der
from py1815.profile import load_map
der = Der(lab, load_map())
nameplate = await der.read(group="nameplate")
for reading in nameplate.readings:
print(reading.point.name, reading.value, reading.point.units, reading.quality)
written = await der.write({"AO87": 50}, verify=True) # 50 percent, sent as 500
print(written.accepted, written.verified)
await der.write_curve(1, type=2, x_units=129, y_units=2,
points=[(920, 300), (980, 0), (1020, 0), (1080, -300)])
await der.write({"Volt-Var Curve Index": 1})
switched = await der.enable("volt-var")
print(switched.enabled) # read back from its input
| Operation | Does |
|---|---|
read(*names, group=) |
Reads points by address (AI148) or name, or a group: a function, or everything of one purpose such as nameplate or monitoring. Returns Readings, each with its value in engineering units, raw, state and quality |
write(values, verify=, mode=, variation=) |
Writes outputs by address or name in one request. A value is divided by the point's multiplier and sent as the nearest whole number; one outside the point's range raises ValueError before anything is sent. Returns Written |
enable(function), disable(function) |
Latches the function's enable output, then reads the input that reports whether it is enabled. Returns Switched |
functions() |
Reads which functions the outstation supports and which are enabled. Returns Functions |
curve(number=None) |
Reads the curve the curve block shows, selecting number first when given |
write_curve(number, type=, x_units=, y_units=, points=) |
Writes the selector, then the fields, then the points, each only when the one before was accepted, and reads the curve back. Returns CurveWritten |
compare(document) |
Compares what the outstation serves with a DNP3 Device Profile document. Returns Comparison |
A name the tables give to more than one point, as an output and the input that
reads it back often share one, is looked for among the inputs for a read and
among the outputs for a write; one that still means two points raises
ValueError naming both addresses. A function is found by its key
(volt-var-control), its name, the purpose the tables give it (Volt-Var), or
its enable output (BO29).
verify=True reads the input that mirrors each output afterwards, and
Setpoint.matches says whether it is within one step of the multiplier. Every
write is a control: it is sent once, and one whose answer did not arrive is
reported with accepted None and is not sent again. One profile operation is
under way on an outstation at a time, so a curve's selector cannot be moved by
another caller before its points are written.
LoopbackDer(loopback, point_map) has the same operations on a Loopback.
Driving an outstation by hand¶
manual=True turns off every task and stops the master confirming anything.
Nothing is then sent that you did not ask for, which is what a test of an
outstation's own behavior needs: its restart indication is still set when you
look, and events left unconfirmed are still there on the next scan.
It is shorthand for tasks=Tasks.none(), confirm=False, and either given
beside it is kept as given: manual=True, confirm=True sends nothing unasked
but confirmations. Reconnecting is apart from it, since it sends nothing to
the outstation; turn it off with reconnect=None.
Misbehaving on purpose¶
A master that evaluates an outstation has to be able to do the wrong thing, so a test can see whether the outstation rejects what the standard says it must. A deviation is a named protocol rule the master breaks. Each is off until turned on, and it changes only what the master sends, never what it expects back:
lab.deviations.corrupt_header_crc = True
poll = await lab.integrity_poll()
assert poll.outcome is Outcome.TIMEOUT # the outstation ignored the bad frame
lab.deviations.corrupt_header_crc = False
| Deviation | What the master does |
|---|---|
silence |
Sends nothing |
repeat_request |
Sends each request twice, octet for octet |
second_master_address |
Sends every frame from the next master address above its own |
corrupt_header_crc, corrupt_body_crc |
Flips a checksum of a request's first frame |
truncate |
Drops the last octet of a request's first frame |
overstate_length |
Claims more user data than a request's first frame carries |
break_transport_sequence |
Sends a request's first segment with a sequence number out of order |
orphan_segment |
Sends a request's first segment as a continuation, with no first segment |
endless_series |
Sends a request's last segment with its final bit clear, so the series never ends |
withhold_confirmation |
Sends no confirmation of a solicited response |
late_confirmation |
Sends the confirmation of each solicited response after its next request, and not when the response arrives |
wrong_confirmation_sequence |
Confirms a solicited response with a sequence number the outstation did not send |
withhold_unsolicited_confirmation |
Sends no confirmation of an unsolicited response |
They are applied to the frame the master built on its way out, so with every one off the master is conformant.
A confirmation deviation acts on the application confirmation of the kind of response it
names. The first three leave the confirmation of an unsolicited response as built, and
the last leaves the confirmation of a solicited one. A link-layer acknowledgment the
master sends with a confirmation is sent as built. A confirmation held back by
late_confirmation is dropped when the deviation is turned off, and no more than a few
are held: the oldest is dropped first.
A frame that is already full cannot claim a greater length, so overstate_length drops
that frame's last data block instead. When several are on, each is applied in turn.
Whether an outstation answers a master it was not told of is its own choice, which its
Device Profile states. With second_master_address on, a response is addressed to that
other master, so the request ends as a timeout either way and the trace shows whether
anything came back.
py1815-master evaluate --allow-deviations runs checks built from these and reports a
verdict for each. See The deviation set.
The trace and a capture keep what the master sent. A frame that no outstation accepts is listed as written, with what is wrong with it:
lab.deviations.corrupt_header_crc = True
await lab.integrity_poll()
print(lab.trace.since()[-1].summary)
# not a valid frame: header CRC does not match
Sending a request by broadcast, or by a function code an outstation does not implement, is
an ordinary request rather than a deviation: use broadcast and request.
Repeating a scan¶
Nothing is sent on a schedule unless you ask for it:
lab.repeat_scan("integrity", 30) # every thirty seconds
lab.repeat_scan("outputs", 30) # output status, which the integrity poll leaves out
lab.repeat_scan("events", 2)
lab.repeat_scan("events", None) # stop
A repeated scan takes its turn with every other request, stops when the connection ends, and starts again when the connection is made again.
The traffic¶
Each outstation keeps a trace: every frame sent and received, with its time,
its octets and a reading of them one layer at a time.
for entry in lab.trace.since():
print(entry.direction, entry.summary)
# tx READ seq 0: class 1, class 2, class 3, class 0
# rx RESPONSE seq 0 CON [NEED_TIME, DEVICE_RESTART]: g1v2 x49, g20v1 x4, g21v5 x4, g23v5 x4, g30v1 x283
# tx CONFIRM seq 0
# tx WRITE seq 1
# rx RESPONSE seq 1 [NEED_TIME]
# tx WRITE seq 2
# rx RESPONSE seq 2
# tx READ seq 3: class 1, class 2, class 3, class 0
# rx RESPONSE seq 3: g1v2 x49, g20v1 x4, g21v5 x4, g30v1 x283
That is a connection being made to an outstation that has just started: the poll that finds it restarted, the two writes that clear the restart and set its clock, and the poll made again once they have.
Without an event loop¶
py1815.master.sync is the same master for a script that has no event loop.
Every request blocks until it is done:
from py1815.master.sync import Master
with Master() as master:
lab = master.add("lab", host="192.0.2.10")
lab.idle()
poll = lab.integrity_poll()
print(poll.outcome, lab.store.analog_input(4))
lab.wait_for(lambda store: store.analog_output(87) is not None, timeout=10)
The master runs on an event loop of its own in a thread it starts and stops,
so the tasks, unsolicited responses and repeated scans carry on between calls.
The store, the trace and the deviations are read and changed on that thread, so
lab.deviations.silence = True takes effect between two sends and never during one.
Its blocking methods are made from the asynchronous ones, and a test fails if
an operation is added to one and not the other.
From the command line¶
py1815-master poll reads an outstation once: one integrity poll, with
confirmations and nothing else, and a summary and the analog inputs, named
from the IEEE 1815.2 tables when this machine has them. py1815-der poll is
the same command.
py1815-master poll --host 192.0.2.10 --port 20000 --limit 20
py1815-master poll --host 192.0.2.10 --tls-ca lab-ca.pem \
--tls-certificate master.pem --tls-key master.key
py1815-master evaluate runs checks against an IEEE 1815.2 DER and prints a
verdict for each: the monitoring, alarm and state points, the connect switch,
stop and start, curves, and each of the profile's functions. It only reads
unless started with --allow-control. See
Evaluating an outstation.
py1815-master evaluate --outstation lab=192.0.2.10:20000
py1815-master evaluate --outstation lab=192.0.2.10:20000 --allow-control \
--report lab.json --capture lab.pcap
Captures¶
A trace can be saved as a pcap file, which Wireshark, tshark and Suricata read as it is:
Each frame is one TCP segment of an IPv4 connection, carried in Ethernet, at
the time it was sent or received. Each connection the master made is a TCP
stream of its own, between the addresses and ports it really had, opened with
a handshake and closed with FIN segments. The file is written from the trace
and not observed on the network, so it needs no capture tool and no
privileges, and it holds exactly the frames the master sent and received. It
does not show how the operating system split the stream into segments, or
retransmissions and acknowledgments of its own. An address that is not IPv4
is written as 127.0.0.1, with its port kept. capture(after=id) keeps only
the frames after one, as since does.
To write every frame to a file as it crosses the wire, from the start of a run, give the command a file:
console takes --capture as well, and the configuration file takes
"capture". Every outstation's frames go to the one file, each connection a
stream of its own, and each packet is on disk as soon as it is written, so a
master that is stopped or killed leaves a file Wireshark can open. The file is
emptied when the master starts. From Python, a CaptureFile and a Recorder
on a trace's listeners do the same:
from py1815.master.capture import CaptureFile
from py1815.master.trace import Recorder
written = CaptureFile("lab.pcap")
recorder = Recorder(written, lab.trace, port=lab.port)
lab.trace.listeners.append(recorder.record)
...
recorder.close() # ends the open connection in the file
written.close()
The console's Traffic tab has a Save capture button, and the service a
capture operation, that return what the trace holds.
Running for days¶
The master is meant to stay connected for as long as it is left running. Every buffer it keeps in memory has a fixed limit, so its memory does not grow with time, and what is written to disk is rotated by size.
| Kept | Limit | When full |
|---|---|---|
| The trace (frames), per outstation | 5,000 frames | The oldest is dropped |
| The event store, per outstation | 10,000 events | The oldest is dropped |
| Unsolicited responses, per outstation | 1,000 | The oldest is dropped |
| The last value of each point | One per point | Replaced |
| Updates waiting for a subscriber (the console, or a script on the line service) | 10,000 | Replaced by one lost update that says how many were dropped |
| The console's frames, events and log, in the browser | 600, 1,000 and 300 | The oldest is dropped |
A console that falls behind, for example in a laptop that slept, is sent a
lost update and reloads what it shows. An event stream that has not accepted
a write for 60 seconds is closed, and the browser opens a new one and reloads.
For a record that outlives the trace, write it to disk:
--capture FILEwrites every frame to a pcap file. The file is rotated at--capture-max-mbmegabytes (100 by default), keeping--capture-keepolder files (10 by default) namedFILE.1,FILE.2and so on before the extension:master.pcap,master.1.pcap. Each file opens with its connections' handshakes, so it reads on its own.--log-file FILEwrites the log to a file, rotated at--log-max-mbmegabytes (10 by default), keeping--log-keepolder files (5 by default). The file gets--log-leveland above (infoby default); the terminal shows warnings only, unless--verbose.
The log records, at INFO:
- What the master was started with, and each outstation's address.
- Each connection made, lost and made again.
- Every command sent to an outstation (operate, the time, the restart
indication, freeze, restart, and any request that is not a read), with all
the points and values asked for and whether the outstation accepted. A command
refused because the service was not started with
--allow-controlis logged as a warning. - Each time the master clears the restart indication or writes the time by itself, and how the request ended.
An outstation that stays unreachable is logged once when the retries begin and then once an hour, with the number of attempts, instead of at every attempt. Each attempt is still logged at DEBUG.
The master does not keep a history of measurements: it is a test tool, not a historian. Use the capture file for a complete record of what was exchanged.
All of these settings are also in the configuration file.
From a browser, or from another process¶
The same master can be driven without writing Python:
- The console is a web page that shows an outstation's points,
events and traffic.
py1815-master console --demostarts it beside a simulated DER. - The master's API is every operation as JSON, over HTTP or a line at a time over a local socket, described in an OpenAPI document.
- Configuring the master lists every setting, as a JSON file and as command-line flags.
In one process, with no socket¶
Loopback wires a master straight to a Session. It has the same requests,
and they return at once and are not awaited:
from py1815.master import Loopback
master = Loopback(outstation.session())
poll = master.integrity_poll()
unasked = master.listen() # what the session would send without being asked
Retries and broadcasts work here as they do over a socket: a read with
master.association.read_retries set is sent again when the session did not
answer it, and a broadcast is handed to the session, which acts on it and
answers nothing.
A Loopback does nothing unasked unless it is given tasks. Given them,
start() stands for the connection being made and returns the exchanges it
led to, and whatever comes due later is done after the request or the
listen() that showed it, and kept in master.unasked:
master = Loopback(outstation.session(), tasks=Tasks())
for exchange in master.start():
print(exchange.task, exchange.outcome)
Give the session and the Loopback the same clock and a test moves time
itself, through as many retries as it likes.
What this does not prove
A master and an outstation from one library share their reading of the standard. When they agree, that is convenient and proves little. The decoders here are tested from octets written out by hand, and the independent masters and parsers described in Testing are what judge whether the outstation's octets are right.