Repository navigation
Evok 3.1.0 - #276
Merged
Merged
Evok 3.1.0#276
Conversation
- new modbus/base.py with IODevice: alias, circuit, client and major_group, get(), set_alias() and _with_alias(); all modbus devices inherit from it and set devtype as a class attribute - remove the unused legacy_mode from IOParser and the devices - remove simple() (never called) from the modbus and 1-Wire devices - remove get_state()/set_state() and ULED.value_delta(); the RPC handler uses get()/set() and keeps its response format - NvSave: write the coil only for value=1, wait 200 ms, report value=1 only in the set() response; full() has no side effect - Watchdog: read registers directly, simplify the timeout limit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- add .flake8 with max-line-length = 120 - whitespace, indentation, blank lines, redundant backslashes and one-line ifs fixed with autopep8 - wrap lines longer than 120 characters - use 'except Exception' instead of bare except - evok.py: move all imports above the logging setup - owdevice.py: use 'is not None' and 'not in' Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- new modbus/iomode.py: IOMode keeps the mode register, the mode definitions, the current mode and its unit and range; update() reads the mode from the cache, set() validates the mode and writes it - WithIOMode mixin exposes mode, modes, mode_value, regmode, unit_name and range on the device, so full() and the API are unchanged - AnalogInput, AnalogOutput and AnalogOutputBrain use IOMode instead of three copies of reload_mode()/_apply_mode_data() - AnalogInput builds the transformation only when the mode changes - set() with an unknown mode now raises ValueError on all analog outputs, it was silently ignored on AO and Brain AO - Client.read_i16(); DataPoint signed16 is sign-extended (xfail removed) - tests for IOMode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Reader reads a value from the Client cache and applies
val * ratio + offset and rounding to decimals; subclasses implement
only read_raw() for uint16, int16, uint32, int32 and float32
- 32-bit readers share Reader32 with word_order ("little"/"big")
- ReaderFactory.get() selects the class by datatype ('signed16' is an
alias of int16 used by data_point), returns a Reader reading None for
an unknown datatype, rejects word_order for 16-bit datatypes
- Reader.refactor() creates a reader of the same register, only index
and is_input are kept, other parameters come from the call
- tests for readers, factory, transformations and refactor()
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AnalogInput reads the value through a Reader; the mode transformation is built by Reader.refactor(), a mode without transformation reads float32 with 3 decimals - AnalogOutput and AnalogOutputBrain derive from AnalogInput, the default reader is selected by _default_reader(), full() takes 'dev' from devtype - AnalogOutput reads uint16 * 0.0025, the reader does not depend on mode - AnalogOutputBrain reads the value or the resistance by the mode, set_value() checks the range of the current mode, the value cannot be set in Resistance or unknown mode, a mode change to Voltage/Current sets the value to 0 - DataPoint reads through a Reader, valid mask moved to new OwTemperature subclass used by the builder when valid_mask_reg is set; unused post_write removed - tests updated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Register reads uint16 through the DataPoint reader, set() writes the register, full() returns the value from the last scan - registers are eventable devices, a change of the value emits an event - value property and regvalue() removed, unused post parameter removed - tests updated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- new DIMode in iomode.py keeps the DirectSwitch mode and ds_mode of a digital input selected by its bit in the shared mode, polarity and toggle registers; update() reads the mode from the cache and reports a change, set() read-modify-writes the bits; WithDIMode mixin exposes the mode attributes - DigitalInput uses DIMode, a mode change emits an event; reads value, counter and debounce through Readers, a disabled counter reads 0 - new ReaderBit reads a bit selected by mask from a 16-bit register; DigitalOutput, Relay, ULED and DigitalInput read the value through it - new Client.write_u16() writes a register and updates the cache - new pwm.py: PwmFrequency shared by the DO group with HardPwmFrequency (48 MHz timer, cycle * prescale) and SoftPwmFrequency (presets or 1000 Hz / (1 + prescaler)); converts the duty between percent and the register value, a frequency out of range raises ValueError - DigitalOutput takes pwm and pwmdutyreg, set_pwm_freq() keeps the duty of all outputs in the group; digital_only, block_pwm and copy() removed - ULED derives from Relay, builder passes coil, reg, mask - RPC input_get/input_get_value read from get(), DigitalInput.get() and get_value() removed - tests updated, new tests for DIMode, ReaderBit and PWM frequency Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MySensor.set returned None, unlike the other devices. DataPoint.set always raised; now it sets the alias and returns full(), and raises ValueError only when a value is given, since a data point is read-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prepares the classes for writing values, not only reading them. Renames the Reader classes, ReaderFactory, the reader attributes of devices and tests/test_reader.py. No functional change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accessor.write applies the inverse transformation, rounds integer datatypes and checks their range, rejects input registers and writes through new Client.write_* methods, which update the cache. AccessorBit does a read-modify-write of the masked bits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Register, AnalogOutput, AnalogOutputBrain, DigitalInput (counter, debounce), DigitalOutput (PWM duty) and Watchdog write through their accessors, so the cache is updated right after the write and values out of range of the register are rejected. Register.set rejects input registers instead of writing the holding register at the same address. AnalogOutput.set_value rounds to the nearest step instead of truncating and returns the value as full() reports it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DataPoint.set writes the value through its accessor when the data point has writable: true in the HW definition. The flag is false by default, so the existing data points stay read-only. Register is always writable and uses DataPoint.set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add POST /{format}/data_point/{circuit} with Data_point_params to the
API documentation and a JSON schema, which accepts only value and
alias. The value may be a string, as form posts send strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Form posts send strings, so the register schema rejected every /rest/register write. The range is still checked for numbers. The di example used value, which the di schema does not allow. All schema examples are now tested against their schemas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A register keeps the bits of 16 outputs, but all outputs of a feature read val_reg, so the outputs 17-32 reported the bits of 1-16. The register is now val_reg + counter // 16, the coils stay contiguous. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IOMode reads and writes the mode register with AccessorU16, DIMode uses AccessorBit for the mode, polarity and toggle bits and OwTemperature reads its validity bit with AccessorBit. The regmode, regpolarity and regtoggle device properties are removed. IOMode.set() without a mode register writes nothing and accepts any defined mode. OwTemperature reports valid=False before the first scan instead of 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HardPwmFrequency and SoftPwmFrequency read the registers with AccessorU16. _write() still writes through mb_client and updates the cache after all writes, so update() never sees a partial setting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tail of the log file is read by GET /log?lines=N instead of ModbusScanner.set(print_log=1), which returned bytes that could not be serialized to JSON. The log is read in Python in a thread, the unused evok_config parameter of ModbusScanner is removed. NvSave keeps value=1 for 0.5 s after the write instead of sleeping 0.2 s in set() and reporting 1 only in its response. A new write is refused while the timer runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
set() of all devices returns None, the REST and JSON API handlers and the RPC methods return device.full() after it. RPC: the owbus_set, owbus_scan and sensor_set methods await the async set() instead of returning a coroutine, ao_set takes mode instead of frequency, an invalid timeout raises ValueError and the ai_set* methods for parameters AI does not have are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split the relay schema from the DO one, add schemas for nv_save and the missing devtype altnames, drop fields set() does not accept and allow alias for owpower. Convert watchdog reset/nv_save and owbus intervals to numbers, reset the owbus only on a true do_reset. Test that the schema fields equal the set() parameters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n REST GET of a property reads it from full() instead of getattr(). by_name() raises DeviceNotFound for an unknown devtype, circuit or alias. POST rejects devices without a schema and returns only the first line of a schema validation error. Unexpected exceptions are logged with the traceback and reported as an internal error. Fix the list of circuit 'all' and build _get_all() with itertools.chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check the structure of a bulk request with a schema and the assigned values of group_assignments by the device schema before setting any device of the group. Look up the device before its schema, so an unknown device is not found and a device without a schema is a bad request. Report unexpected exceptions as an internal error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSONBulkHandler goes to bulk_handler.py, WsHandler with registered_ws to ws_handler.py. WsHandler gets all_filtered from the route instead of reading the global evok_config. The websocket 'full' command sends the state only to the requesting client without calling full() as a coroutine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Describe the data_point and register values as they are returned (NaN, null before the first read, alias) and accepted (numbers and strings, no other fields). Add shared BadRequest and NotFound responses to the device endpoints and descriptions to the data_point schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A duplicate or invalid alias raises ValueError, so the API reports it as a bad request instead of an internal error. The whole alias must match the allowed characters and an alias cannot be a circuit of the same device type, by_name() would never find it. Clear the aliases between tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
by_int() returned the device of an alias regardless of its type, now it is not found as in by_name(). The alias dicts of Aliases were class attributes shared by all instances. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An alias loaded from the file belongs to its device even when the device is not registered yet (e.g. offline), it cannot be taken by another device and lost on the next save. Such alias can be deleted by POST /rest/run/alias with delete=<alias>. Loading of the alias file does not fail on an unknown or missing devtype, the record is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
full() of the devices tests the alias against '', a None alias was returned as "alias": null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
by_int() and by_name() share the lookup of the device type, an unknown type raises DeviceNotFound in both. by_int() filters by major_group in one list. set_alias() leaves the logging of an invalid alias to the caller. Remove remove_item(), BOARD and the unused unit constants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both looked up a device by the name of its type, by_int() only added the major_group filter. by_name() takes major_group now, unknown circuits are reported with the messages of by_name(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… port 50200 - --config-dir (-d): the directory of autogen.yaml and of hw_definitions (the definitions of the Iris cards), /etc/evok by default, e.g. ~/evok/etc of Evok in a virtual environment - --product-id (-p): the product id in hex (0407 or 0x0407) instead of UNIPI_PRODUCT_ID; the family is the low byte of the id, the last two digits of a 4-digit id before - the default port of LOCAL_TCP is 50200, the Modbus TCP server of the Unipi OS, not 502; LISTEN_PORT of /etc/default/unipitcp goes first as before - Docs: the options, generating autogen.yaml of a virtual environment, the port 50200 of LOCAL_TCP in the examples and of the local I/Os, serial_ports in the autogen example, the API changes (en, cs) - tests: the arguments, the environment, the port, no product id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arguments - the default port of LOCAL_TCP is 502 again, 50200 on Unipi 1; the port of the Modbus TCP server of the Unipi OS is LISTEN_PORT of /etc/default/unipitcp - an invalid --product-id or a missing --config-dir is an error of the arguments with the status 2, it printed "Device not recognized" with the status 0, the missing directory a traceback; nothing is written - Docs: the variables of the environment, also for a run by hand (OWBUS only with HAS_DS2482=1), in the example of a virtual environment too; the rules of the port; the local I/Os on the port LISTEN_PORT - tests: the ports, the invalid arguments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- pyproject.toml and uv.lock: 3.1.0, before 3.1.0a1 - docs: the links to the udev rule of 1-Wire and to examples/config.yaml on main, EVOK_VER=main of the virtual environment, not dev-3.1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/zmeny-api-dev-src.md removed, also from not_in_nav of mkdocs.yml - api-changes-dev-src.md compiled between Evok 3.0 (b2e241c) and Evok 3.1, not between the branches main and dev-3.1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rors
- start_pulse() cancels the running sequence of the output after the
successful write of the new value, under the lock of the unit
(set_devices(replace_sequences=True)): a failed write, e.g. an unavailable
unit, left the output in the state of the old pulse without its end
- get_api() of an empty section (websocket: with the options commented out)
is {}, Evok did not start
- MQTT: an error of the connection which is not an MqttError (e.g. a missing
file of TLS) is logged once and the handler connects again, the loop ended
silently; an MqttError of a publish publishes the state again after the
delay, the publishing task ended silently and the states were not published
- /rest: an empty body with Content-Type application/json sets nothing, it was
a 400
- docs: the API changes against Evok 3.0.6 (main after it had the docs and
#191); EVOK_VER=3.1.0 and the links to the tag 3.1.0; the replacement of
a pulse in sequencer.md
- CI: the integration tests on the pushes to dev-*, not dev-3.1
- tests: the failed pulse, the old end before the new pulse, the empty section,
the MQTT errors, the empty JSON body
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrors, the API sections
- set_devices(sequences={device: Sequence}) starts the new sequence after the
successful set() under the lock of the unit, it replaces the running one;
start_pulse() started the pulse after the read of the unit, a task cancelled
meanwhile left the output on without a sequence
- MQTT: a state refused by the client is published again after the other
pending ones, at most 3 times with 1 s between them, then dropped; it was
retried every 5 s forever and delayed the others. An error other than of
MQTT is logged once also when it comes after each connection, the message
says so
- the sections rpc, websocket, webhook and mqtt of apis are normalized when the
config is read: an empty one is {}, another value than a mapping is an error
of the config, Evok exits with it (also apis which is not a mapping); the
callers do not repeat `or {}`
- tests: the pulse cancelled after its write, the dropped publish, the error
after the connection, the API sections which are not a mapping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The links of the docs and EVOK_VER on the tag 3.1.0 and the API changes against Evok 3.0.6 of dev-3.1 replace the links to main and the wording of the release commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cks again A unit may set a written register with a delay: xS52 sets the mode of its AI in 0.3..0.8 s (checked by Modbus directly, xS51 at once). The read after the write returned the old mode and overwrote the written value in the cache, the reply and the event showed the old mode, the switch seemed to fail and the new mode was seen by the next scan of the slow block, several seconds later; a second switch showed the mode of the first one. - Client.change() keeps the values written by write_registers() in the cache after the read, the reply and the event have them - ModbusCacheMap.read_soon(): the blocks of the written registers are read by the periodic scan after REREAD_DELAY (1 s), also out of their scan_divider, their phase is kept; a value refused by the unit is seen then - docs: the written registers after a change in the API changes, the check of the firmware before the release is done - tests: the kept value, the block read again with the set and the refused value Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of #278: the written value was kept only against the read of the change. A periodic scan of the block before the unit set it (a block read by every scan, or a slow one on its turn) read the old value and sent it, 2 -> 1 -> 2; the timer of an earlier write could read the block just after a next write; a unit without the periodic scan never read a refused value again. - ModbusCacheMap.pin(): a written holding register keeps its value for REREAD_DELAY, every read of its block keeps it (also get_register_async(), a read-modify-write of a bit uses the pinned value); a next write renews it. A group with an expired pin is read by the next periodic scan, out of its scan_divider and without a change of its counter, then the read value is used - Client.write_registers() pins the values of a unit with the periodic scan, a unit without it shows the value read after the change - read_soon(), the timers and Client._scan(written) removed - docs: the pinned registers in the API changes; the check of the outputs written by a coil before the release - tests: the block read after the pin, the scan during the pin, the renewed pin, a unit without the periodic scan, a bit written to the pinned value Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… long change - write_coil_without_response() (reset, NV save) drops the pins of the unit: the read after the restart showed the written values instead of the saved settings - a write without a pin (Accessor pin=False, e.g. the counter of a DI the unit counts on, or a unit without the periodic scan) drops the older pins of its registers, they overwrote the written value; the counter is not pinned, the read shows the count, it was 0 for 1 s - RegisterGroup.pin() does not set the cache, the write or its caller does: PWM with cache=False updates it after all its writes, a check saw a partial setting - the pins of a change are renewed at its end, a change longer than REREAD_DELAY lost the pins of its first writes before its read - get_register_async() returns the cached values of the blocks, pin_expired() skips a group without pins - tests: the restart, the counter, the unpinned write, cache=False, a long change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ias before the write - sequencer: a failed step is written again every STEP_RETRY_DELAY (1 s) until it succeeds or the sequence is cancelled, it is logged once; the last step ends the sequence after its successful write. A failed end of a pulse (e.g. a noise on the bus) ended the sequence before the write, the relay stayed on and finish_all() did not write it either. finish_all() does not write the final state of a sequence writing its last step again - Watchdog: a changed timeout is an event; the timeout is checked before any change, a whole number of ms (it was cut by int()), an invalid one enabled the watchdog and set the alias before the error - set_devices() checks the aliases of the assignments before the first write (Devices.check_alias()): the output was switched and then the request failed on a used, invalid or reserved alias - AccessorBit writes with its pin - docs: the retried step, the watchdog timeout, the alias in the API changes and sequencer.md - tests: the retried step and the end after the write, the failed end of a DO pulse written again, the watchdog timeout, the alias, the bit without a pin Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… errors, the aliases in order - finish_all() writes the final state of a sequence whose last step was being written and failed (Running.finished after the successful write), it left the relay on - only the errors of the communication are written again (UnitCommunicationError, TModbusError, TimeoutError, OSError), another error ends the sequence and is logged; a value refused by the output was written again forever and the output refused every change - the step is done after its successful write, a step written again was reported as done - set_devices() checks the aliases in the order of the changes (by the units), with the aliases released and taken by the earlier assignments: the same alias twice in one request passed the check, an alias moved to another device was refused - set_alias() checks the alias by check_alias(), the rules in one place - docs, tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ams, a non-string alias - a step refused by the unit (an exception response Illegal Function, Data Address or Data Value, an invalid request) is not written again, it was an error of the communication (TModbusError) and was retried forever - a step which ends its sequence by an error writes the final state of the sequence, the output stayed in its state - the delay of a failed step doubles from 1 s up to 30 s, a missing unit does not load its bus every second; the failure is logged again when the error (with its cause) changes and every 5 minutes - Watchdog.set() checks value and reset before any change too, an invalid one set the alias, enabled the watchdog or wrote the timeout before the error - an alias which is not a string (0, false) is refused as invalid before the write, it passed as a reset and failed by TypeError after the write - docs, tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on responses, the alias type - the final state after a refused step is written as the last step of the sequence while it runs: it ended before, a new pulse or a change in the gap was overwritten by the old final state and finish_all() did not wait for it; an error of the communication writes it again, a noise lost it - every exception response of the unit is a refused request (e.g. Server Device Failure was retried forever) except Server Device Busy, Acknowledge and the errors of a gateway, which are written again - a failed step is logged again when the kind of its error changes, the text of a timeout differs by each attempt and was logged each time - _check_aliases() checks the alias by check_alias() before the taken ones, a list alias raised TypeError (500) - docs: the final state only of a sequence which has one, the watchdog timeout is cut to 65535 - tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… without the coil - _check_aliases() skips an alias null as not given: it was checked as a reset and released the alias for another device, set_alias() keeps it and the duplicate failed after the write - the final state after a refused step is not written when it is the refused step itself, e.g. the end of a pulse was sent again and logged twice - Watchdog.set() ignores reset of a unit without the reset coil as before, an invalid one was refused - docs: the PRs of the new entries of the API changes - tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ias before the write (#280)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
matoushybl
force-pushed
the
release-3.1.0
branch
from
November 2, 2026 14:35
f43ab64 to
10c7be5
Compare
matoushybl
force-pushed
the
release-3.1.0
branch
from
November 2, 2026 14:38
10c7be5 to
768ef9e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Evok 3.1.0:
dev-3.1intomain(271 commits,mainhas no commit missing indev-3.1), with the release commits on top ofdev-3.1.The release commits
pyproject.tomlanduv.lock: version3.1.0, before3.1.0a1.docs/zmeny-api-dev-src.mdremoved.The links of the docs to the tag
3.1.0(EVOK_VER, the udev rule of 1-Wire,examples/config.yaml) and the API changes against Evok 3.0.6 came fromdev-3.1(#277), the branch is merged into the release branch. The links work after the tag3.1.0is created.Evok 3.1
The changes against 3.0 which affect the clients of the APIs, the configuration and the HW definitions, breaking changes first: docs/api-changes-dev-src.md; the major changes in
README.md(#260).The PRs merged into
dev-3.1:Testing
pytest tests: 1302 passed;uv lock --checkpasses; the wheel builds asevok-3.1.0-py3-none-any.whl.Not changed
docs/spec.mdreadsEvok_API_OAS.yamlof the latest release, the workflowopenapiattaches it to the release 3.1.0.🤖 Generated with Claude Code