diff --git a/.gitignore b/.gitignore index f0b60c9e..f2e555c8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ mono_crash.*.json # Godot 4+ specific ignores .godot/ +/override.cfg +/scripts/commit.gd diff --git a/addons/godot-logger/README.md b/addons/godot-logger/README.md index 0d6f5981..416382e0 100644 --- a/addons/godot-logger/README.md +++ b/addons/godot-logger/README.md @@ -33,7 +33,7 @@ Log.fatal(text) const CUSTOM = Log.MAX << 1 # Bitwise left shift the MAX value for a custom level. func _ready() -> void: - Log.add_level(CUSTOM, "Level Name") + Log.add_level(CUSTOM, "Level Name") ``` ## Calling the custom level: diff --git a/addons/godot-logger/plugin.gd b/addons/godot-logger/plugin.gd index 0062df06..a6ff3f7f 100644 --- a/addons/godot-logger/plugin.gd +++ b/addons/godot-logger/plugin.gd @@ -5,7 +5,7 @@ extends EditorPlugin -const AUTOLOAD_NAME = "Log" +const AUTOLOAD_NAME = "log" const AUTOLOAD_PATH = "res://addons/godot-logger/scripts/logger.gd" const LOGGER_OUTPUT = "LoggerOutput" @@ -13,11 +13,11 @@ const LOGGER_OUTPUT_SCRIPT = "res://addons/godot-logger/scripts/logger_output.gd const LOGGER_OUTPUT_ICON = "res://addons/godot-logger/icons/logger.svg" -func _def_settings(name: String, default: Variant) -> void: - if not ProjectSettings.has_setting(name): - ProjectSettings.set_setting(name, default) +func _def_settings(pname: String, default: Variant) -> void: + if not ProjectSettings.has_setting(pname): + ProjectSettings.set_setting(pname, default) - ProjectSettings.set_initial_value(name, default) + ProjectSettings.set_initial_value(pname, default) func _enter_tree() -> void: @@ -37,8 +37,8 @@ func _enter_tree() -> void: _def_settings("plugins/logger/file/file_path", "user://uarep-ctl.log") _def_settings("plugins/logger/file/log_file_write", true) - _def_settings("plugins/logger/default_output_format", "[{hour}:{minute}:{second}][{level}]{text}") - _def_settings("plugins/logger/file_format", "[{hour}:{minute}:{second}][{level}]{text}") + _def_settings("plugins/logger/default_output_format", "[{year}-{month}-{day} {hour}:{minute}:{second}][{level}]{text}") + _def_settings("plugins/logger/file_format", "[{year}-{month}-{day} {hour}:{minute}:{second}][{level}]{text}") add_autoload_singleton(AUTOLOAD_NAME, AUTOLOAD_PATH) add_custom_type(LOGGER_OUTPUT, "RichTextLabel", load(LOGGER_OUTPUT_SCRIPT), load(LOGGER_OUTPUT_ICON)) diff --git a/addons/godot-logger/scripts/logger.gd b/addons/godot-logger/scripts/logger.gd index 9567d96d..1648f794 100644 --- a/addons/godot-logger/scripts/logger.gd +++ b/addons/godot-logger/scripts/logger.gd @@ -32,12 +32,14 @@ var _format_default_output : String var _format_file : String var _names = { - INFO: "INFO", - DEBUG: "DEBUG", - WARNING: "WARNING", - ERROR: "ERROR", - FATAL: "FATAL" + INFO: "ИНФО", + DEBUG: "ОТЛАДКА", + WARNING: "ВНИМАНИЕ", + ERROR: "ОШИБКА", + FATAL: "АВАРИЯ" } +var _msg_array: Array +var _filters: Array # Can be overridden to define custom default values. func _enter_tree() -> void: @@ -49,8 +51,8 @@ func _enter_tree() -> void: set_file_path(ProjectSettings.get_setting("plugins/logger/file/file_path", "user://uarep-ctl.log")) set_file_write_enabled(ProjectSettings.get_setting("plugins/logger/file/log_file_write", true)) - _format_default_output = ProjectSettings.get_setting("plugins/logger/default_output_format", "[{hour}:{minute}:{second}][{level}]{text}") - _format_file = ProjectSettings.get_setting("plugins/logger/file_format", "[{hour}:{minute}:{second}][{level}]{text}") + _format_default_output = ProjectSettings.get_setting("plugins/logger/default_output_format", "[{year}-{month}-{day} {hour}:{minute}:{second}][{level}]{text}") + _format_file = ProjectSettings.get_setting("plugins/logger/file_format", "[{year}-{month}-{day} {hour}:{minute}:{second}][{level}]{text}") func _exit_tree() -> void: @@ -158,6 +160,9 @@ func fatal(text: String) -> void: func format_file(msg: Dictionary) -> String: return _format_file.format( { + "year": "%02d" % msg["year"], + "month": "%02d" % msg["month"], + "day": "%02d" % msg["day"], "hour": "%02d" % msg["hour"], "minute":"%02d" % msg["minute"], "second":"%02d" % msg["second"], @@ -170,6 +175,9 @@ func format_file(msg: Dictionary) -> String: func format_stdout(msg: Dictionary) -> String: return _format_default_output.format( { + "year": "%02d" % msg["year"], + "month": "%02d" % msg["month"], + "day": "%02d" % msg["day"], "hour": "%02d" % msg["hour"], "minute":"%02d" % msg["minute"], "second":"%02d" % msg["second"], @@ -184,15 +192,19 @@ func message(level: int, text: String) -> void: assert(text, "Invalid message text.") if _log_enabled and _level & level: - var msg : Dictionary = Time.get_time_dict_from_system() + var msg : Dictionary = Time.get_datetime_dict_from_system() msg["level"] = level msg["level_name"] = _names[level] msg["text"] = " " + text - logged.emit(msg) + _msg_array.append(msg) + if (_flt_msg(msg)): + logged.emit(msg) + if _file_write_enabled: _file.store_line(format_file(msg)) + _file.call_deferred('flush') if _default_output_enabled: print(format_stdout(msg)) @@ -202,17 +214,53 @@ func _open_file() -> void: if is_instance_valid(_file) and _file.is_open(): _file.close() - _file = FileAccess.open(get_file_path(), FileAccess.WRITE) + _file = FileAccess.open(get_file_path(), FileAccess.READ_WRITE) var err := FileAccess.get_open_error() if err: + _file = FileAccess.open(get_file_path(), FileAccess.WRITE) + _file.close() + _file = FileAccess.open(get_file_path(), FileAccess.READ_WRITE) return print_debug(error_string(err)) err = _file.get_error() if err: return print_debug(error_string(err)) + _file.seek_end(0) func _close_file() -> void: if is_instance_valid(_file) and _file.is_open(): _file = null + + +func save_file(path): + var f = FileAccess.open(path, FileAccess.WRITE) + var err := FileAccess.get_open_error() + if err: + print_debug(error_string(err)) + var log_text = _file.get_as_text() + f.store_line(log_text) + f.close() + + +func set_filtrs(flt_array: Array): + _filters = flt_array + + +func _flt_msg(msg): + var flt_arr: Array = [] + for item in _filters: + if item.button_pressed: + flt_arr.append(item.get_meta('filter')) + if msg['text'].find(item.get_meta('filter')) != -1: + return true + if msg['level_name'] in flt_arr: + return true + return false + + +func add_msgs(): + for msg in _msg_array: + if (_flt_msg(msg)): + logged.emit(msg) diff --git a/addons/godot-logger/scripts/logger_output.gd b/addons/godot-logger/scripts/logger_output.gd index 4b0b9d3d..539daac2 100644 --- a/addons/godot-logger/scripts/logger_output.gd +++ b/addons/godot-logger/scripts/logger_output.gd @@ -10,7 +10,7 @@ extends RichTextLabel signal handled() -@export var format_text : String = "[{hour}:{minute}:{second}][{level}]{text}" +@export var format_text : String = "[{year}-{month}-{day} {hour}:{minute}:{second}][{level}]{text}" @export var colors : Dictionary = { Logger.INFO: Color.WHITE, Logger.DEBUG: Color.GRAY, @@ -24,7 +24,7 @@ var _logger : Logger func _enter_tree() -> void: - var logger := get_node_or_null("/root/Log") as Logger + var logger := get_node_or_null("/root/log") as Logger if is_instance_valid(logger): set_logger(logger) @@ -59,6 +59,9 @@ func get_logger() -> Logger: func format(message: Dictionary) -> String: return format_text.format( { + "year": "%02d" % message["year"], + "month": "%02d" % message["month"], + "day": "%02d" % message["day"], "hour": "%02d" % message["hour"], "minute": "%02d" % message["minute"], "second": "%02d" % message["second"], @@ -72,5 +75,4 @@ func handle_message(message: Dictionary) -> void: push_color(get_color(message["level"])) append_text(format(message) + "\n") pop() - handled.emit() diff --git a/build-godot-engine.sh b/build-godot-engine.sh deleted file mode 100644 index 823a1316..00000000 --- a/build-godot-engine.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -scons target=template_release production=yes use_lto=yes -j4 diff --git a/data/main-font.tres b/data/main-font.tres index f75961c1..92f7276c 100644 --- a/data/main-font.tres +++ b/data/main-font.tres @@ -1,9 +1,20 @@ -[gd_resource type="FontFile" load_steps=2 format=2] +[gd_resource type="FontFile" load_steps=2 format=3 uid="uid://clrxfflc1wrhj"] -[ext_resource path="res://data/DejaVuSansCondensed.ttf" type="FontFile" id=1] +[ext_resource type="FontFile" uid="uid://c7hvei2ks7jp6" path="res://data/DejaVuSansCondensed.ttf" id="1"] [resource] -size = 20 -use_mipmaps = true -use_filter = true -font_data = ExtResource( 1 ) +fallbacks = Array[Font]([ExtResource("1")]) +cache/0/16/0/ascent = 0.0 +cache/0/16/0/descent = 0.0 +cache/0/16/0/underline_position = 0.0 +cache/0/16/0/underline_thickness = 0.0 +cache/0/16/0/scale = 1.0 +cache/0/16/0/kerning_overrides/16/0 = Vector2(0, 0) +cache/0/16/0/kerning_overrides/14/0 = Vector2(0, 0) +cache/0/14/0/ascent = 0.0 +cache/0/14/0/descent = 0.0 +cache/0/14/0/underline_position = 0.0 +cache/0/14/0/underline_thickness = 0.0 +cache/0/14/0/scale = 1.0 +cache/0/14/0/kerning_overrides/16/0 = Vector2(0, 0) +cache/0/14/0/kerning_overrides/14/0 = Vector2(0, 0) diff --git a/data/nine-patch-round.png b/data/nine-patch-round.png new file mode 100644 index 00000000..172b65ae Binary files /dev/null and b/data/nine-patch-round.png differ diff --git a/data/nine-patch-round.png.import b/data/nine-patch-round.png.import new file mode 100644 index 00000000..2c1939cb --- /dev/null +++ b/data/nine-patch-round.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c4mdrmk7h638f" +path="res://.godot/imported/nine-patch-round.png-40081195f042d0b4e71ddf4d58b0c134.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/nine-patch-round.png" +dest_files=["res://.godot/imported/nine-patch-round.png-40081195f042d0b4e71ddf4d58b0c134.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/secondary-font.tres b/data/secondary-font.tres index 5f7ba84e..bb0ebdd0 100644 --- a/data/secondary-font.tres +++ b/data/secondary-font.tres @@ -4,9 +4,6 @@ [resource] fallbacks = Array[Font]([ExtResource("1_cxid7")]) -face_index = null -embolden = null -transform = null cache/0/17/0/ascent = 0.0 cache/0/17/0/descent = 0.0 cache/0/17/0/underline_position = 0.0 @@ -19,6 +16,7 @@ cache/0/17/0/kerning_overrides/14/0 = Vector2(0, 0) cache/0/17/0/kerning_overrides/8/0 = Vector2(0, 0) cache/0/17/0/kerning_overrides/15/0 = Vector2(0, 0) cache/0/17/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/17/0/kerning_overrides/20/0 = Vector2(0, 0) cache/0/16/0/ascent = 0.0 cache/0/16/0/descent = 0.0 cache/0/16/0/underline_position = 0.0 @@ -31,6 +29,7 @@ cache/0/16/0/kerning_overrides/14/0 = Vector2(0, 0) cache/0/16/0/kerning_overrides/8/0 = Vector2(0, 0) cache/0/16/0/kerning_overrides/15/0 = Vector2(0, 0) cache/0/16/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/16/0/kerning_overrides/20/0 = Vector2(0, 0) cache/0/18/0/ascent = 0.0 cache/0/18/0/descent = 0.0 cache/0/18/0/underline_position = 0.0 @@ -43,6 +42,7 @@ cache/0/18/0/kerning_overrides/14/0 = Vector2(0, 0) cache/0/18/0/kerning_overrides/8/0 = Vector2(0, 0) cache/0/18/0/kerning_overrides/15/0 = Vector2(0, 0) cache/0/18/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/18/0/kerning_overrides/20/0 = Vector2(0, 0) cache/0/14/0/ascent = 0.0 cache/0/14/0/descent = 0.0 cache/0/14/0/underline_position = 0.0 @@ -55,6 +55,7 @@ cache/0/14/0/kerning_overrides/14/0 = Vector2(0, 0) cache/0/14/0/kerning_overrides/8/0 = Vector2(0, 0) cache/0/14/0/kerning_overrides/15/0 = Vector2(0, 0) cache/0/14/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/14/0/kerning_overrides/20/0 = Vector2(0, 0) cache/0/8/0/ascent = 0.0 cache/0/8/0/descent = 0.0 cache/0/8/0/underline_position = 0.0 @@ -67,6 +68,7 @@ cache/0/8/0/kerning_overrides/14/0 = Vector2(0, 0) cache/0/8/0/kerning_overrides/8/0 = Vector2(0, 0) cache/0/8/0/kerning_overrides/15/0 = Vector2(0, 0) cache/0/8/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/8/0/kerning_overrides/20/0 = Vector2(0, 0) cache/0/15/0/ascent = 0.0 cache/0/15/0/descent = 0.0 cache/0/15/0/underline_position = 0.0 @@ -79,6 +81,7 @@ cache/0/15/0/kerning_overrides/14/0 = Vector2(0, 0) cache/0/15/0/kerning_overrides/8/0 = Vector2(0, 0) cache/0/15/0/kerning_overrides/15/0 = Vector2(0, 0) cache/0/15/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/15/0/kerning_overrides/20/0 = Vector2(0, 0) cache/0/13/0/ascent = 0.0 cache/0/13/0/descent = 0.0 cache/0/13/0/underline_position = 0.0 @@ -91,3 +94,17 @@ cache/0/13/0/kerning_overrides/14/0 = Vector2(0, 0) cache/0/13/0/kerning_overrides/8/0 = Vector2(0, 0) cache/0/13/0/kerning_overrides/15/0 = Vector2(0, 0) cache/0/13/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/13/0/kerning_overrides/20/0 = Vector2(0, 0) +cache/0/20/0/ascent = 0.0 +cache/0/20/0/descent = 0.0 +cache/0/20/0/underline_position = 0.0 +cache/0/20/0/underline_thickness = 0.0 +cache/0/20/0/scale = 1.0 +cache/0/20/0/kerning_overrides/17/0 = Vector2(0, 0) +cache/0/20/0/kerning_overrides/16/0 = Vector2(0, 0) +cache/0/20/0/kerning_overrides/18/0 = Vector2(0, 0) +cache/0/20/0/kerning_overrides/14/0 = Vector2(0, 0) +cache/0/20/0/kerning_overrides/8/0 = Vector2(0, 0) +cache/0/20/0/kerning_overrides/15/0 = Vector2(0, 0) +cache/0/20/0/kerning_overrides/13/0 = Vector2(0, 0) +cache/0/20/0/kerning_overrides/20/0 = Vector2(0, 0) diff --git a/data/Изделие МП-550.png b/data/Изделие МП-550.png index 69caf080..41ae78a5 100644 Binary files a/data/Изделие МП-550.png and b/data/Изделие МП-550.png differ diff --git a/data/РТР.png.import b/data/РТР.png.import new file mode 100644 index 00000000..955653b9 --- /dev/null +++ b/data/РТР.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5dm8b0hcmkc3" +path="res://.godot/imported/РТР.png-4dffef42cf4e5844aa84c964b0a8a676.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/РТР.png" +dest_files=["res://.godot/imported/РТР.png-4dffef42cf4e5844aa84c964b0a8a676.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/СПТ.png b/data/СПТ.png index eb44414f..77a456a5 100644 Binary files a/data/СПТ.png and b/data/СПТ.png differ diff --git a/data/УФ.png b/data/УФ.png index 96afac69..d1937ffc 100644 Binary files a/data/УФ.png and b/data/УФ.png differ diff --git a/data/Щ3-mnemo.png.import b/data/Щ3-mnemo.png.import new file mode 100644 index 00000000..7cc0794c --- /dev/null +++ b/data/Щ3-mnemo.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rkvki3gs2kk0" +path="res://.godot/imported/Щ3-mnemo.png-7a21825106619ee1b5125d77505fb52b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/Щ3-mnemo.png" +dest_files=["res://.godot/imported/Щ3-mnemo.png-7a21825106619ee1b5125d77505fb52b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/Щ3.png b/data/Щ3.png index 2806d36d..af4bee29 100644 Binary files a/data/Щ3.png and b/data/Щ3.png differ diff --git a/data/кнопка-квадрат-0.png b/data/кнопка-квадрат-0.png index 72b6a4cb..33ef0699 100644 Binary files a/data/кнопка-квадрат-0.png and b/data/кнопка-квадрат-0.png differ diff --git a/data/кнопка-квадрат-1.png b/data/кнопка-квадрат-1.png index 38fa9abf..ea400aa2 100644 Binary files a/data/кнопка-квадрат-1.png and b/data/кнопка-квадрат-1.png differ diff --git a/data/кнопка-масштаб.png b/data/кнопка-масштаб.png new file mode 100644 index 00000000..f614731a Binary files /dev/null and b/data/кнопка-масштаб.png differ diff --git a/data/кнопка-масштаб.png.import b/data/кнопка-масштаб.png.import new file mode 100644 index 00000000..911dd58a --- /dev/null +++ b/data/кнопка-масштаб.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1maea6yhd3k7" +path="res://.godot/imported/кнопка-масштаб.png-506c26462e59a71e6707c554c495504e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/кнопка-масштаб.png" +dest_files=["res://.godot/imported/кнопка-масштаб.png-506c26462e59a71e6707c554c495504e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/прогресс-заполнение-1.png b/data/прогресс-заполнение-1.png new file mode 100644 index 00000000..1287ed5d Binary files /dev/null and b/data/прогресс-заполнение-1.png differ diff --git a/data/прогресс-заполнение-1.png.import b/data/прогресс-заполнение-1.png.import new file mode 100644 index 00000000..e9c65005 --- /dev/null +++ b/data/прогресс-заполнение-1.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1k856fxhrjnh" +path="res://.godot/imported/прогресс-заполнение-1.png-8f8393b6735f8a6bd330fcb64a737cde.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/прогресс-заполнение-1.png" +dest_files=["res://.godot/imported/прогресс-заполнение-1.png-8f8393b6735f8a6bd330fcb64a737cde.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/прогресс-фон-1.png b/data/прогресс-фон-1.png new file mode 100644 index 00000000..00ddb834 Binary files /dev/null and b/data/прогресс-фон-1.png differ diff --git a/data/прогресс-фон-1.png.import b/data/прогресс-фон-1.png.import new file mode 100644 index 00000000..c0e7e065 --- /dev/null +++ b/data/прогресс-фон-1.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://00871pb6moln" +path="res://.godot/imported/прогресс-фон-1.png-64e1c4edb6f9c678c20df2bdb91e5b2b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/прогресс-фон-1.png" +dest_files=["res://.godot/imported/прогресс-фон-1.png-64e1c4edb6f9c678c20df2bdb91e5b2b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/рамка-1.png b/data/рамка-1.png new file mode 100644 index 00000000..2d9eb639 Binary files /dev/null and b/data/рамка-1.png differ diff --git a/data/рамка-1.png.import b/data/рамка-1.png.import new file mode 100644 index 00000000..b65dd756 --- /dev/null +++ b/data/рамка-1.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0o8jhb5jbrev" +path="res://.godot/imported/рамка-1.png-f12c78d1806344f437cab5886e375731.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/рамка-1.png" +dest_files=["res://.godot/imported/рамка-1.png-f12c78d1806344f437cab5886e375731.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/состояние-исправности-0.png b/data/состояние-исправности-0.png new file mode 100644 index 00000000..33966d1e Binary files /dev/null and b/data/состояние-исправности-0.png differ diff --git a/data/состояние-исправности-0.png.import b/data/состояние-исправности-0.png.import new file mode 100644 index 00000000..947a011d --- /dev/null +++ b/data/состояние-исправности-0.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6nve6f8sfyj2" +path="res://.godot/imported/состояние-исправности-0.png-80d296bfe23ad795df15b10d3cbdc6be.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/состояние-исправности-0.png" +dest_files=["res://.godot/imported/состояние-исправности-0.png-80d296bfe23ad795df15b10d3cbdc6be.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/состояние-исправности-1.png b/data/состояние-исправности-1.png new file mode 100644 index 00000000..755740f3 Binary files /dev/null and b/data/состояние-исправности-1.png differ diff --git a/data/состояние-исправности-1.png.import b/data/состояние-исправности-1.png.import new file mode 100644 index 00000000..5de2583d --- /dev/null +++ b/data/состояние-исправности-1.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dnreyfh3cd1k2" +path="res://.godot/imported/состояние-исправности-1.png-817448af211ad4cecd7fc0b9b688c408.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/состояние-исправности-1.png" +dest_files=["res://.godot/imported/состояние-исправности-1.png-817448af211ad4cecd7fc0b9b688c408.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/состояние-исправности-2.png b/data/состояние-исправности-2.png new file mode 100644 index 00000000..d0ad1982 Binary files /dev/null and b/data/состояние-исправности-2.png differ diff --git a/data/состояние-исправности-2.png.import b/data/состояние-исправности-2.png.import new file mode 100644 index 00000000..1d4b4463 --- /dev/null +++ b/data/состояние-исправности-2.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6booa8753u5t" +path="res://.godot/imported/состояние-исправности-2.png-980a893fd398fba0dfb382e7c7ae4430.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/состояние-исправности-2.png" +dest_files=["res://.godot/imported/состояние-исправности-2.png-980a893fd398fba0dfb382e7c7ae4430.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/состояния-исправности.png b/data/состояния-исправности.png new file mode 100644 index 00000000..8c38808a Binary files /dev/null and b/data/состояния-исправности.png differ diff --git a/data/состояния-исправности.png.import b/data/состояния-исправности.png.import new file mode 100644 index 00000000..969747fe --- /dev/null +++ b/data/состояния-исправности.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl4v556yreno1" +path="res://.godot/imported/состояния-исправности.png-decaf67c3bc5d297844ef43f946cc651.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://data/состояния-исправности.png" +dest_files=["res://.godot/imported/состояния-исправности.png-decaf67c3bc5d297844ef43f946cc651.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/data/состояния-исправности.tres b/data/состояния-исправности.tres new file mode 100644 index 00000000..86f7dbac --- /dev/null +++ b/data/состояния-исправности.tres @@ -0,0 +1,32 @@ +[gd_resource type="SpriteFrames" load_steps=5 format=3 uid="uid://jndvpts2tbi3"] + +[ext_resource type="Texture2D" uid="uid://bl4v556yreno1" path="res://data/состояния-исправности.png" id="1_wb1fe"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_n2u5p"] +atlas = ExtResource("1_wb1fe") +region = Rect2(0, 0, 224, 224) + +[sub_resource type="AtlasTexture" id="AtlasTexture_a4vh8"] +atlas = ExtResource("1_wb1fe") +region = Rect2(224, 0, 224, 224) + +[sub_resource type="AtlasTexture" id="AtlasTexture_qf05b"] +atlas = ExtResource("1_wb1fe") +region = Rect2(448, 0, 224, 224) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_n2u5p") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_a4vh8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qf05b") +}], +"loop": true, +"name": &"default", +"speed": 5.0 +}] diff --git a/docs/build.sh b/docs/build.sh new file mode 100644 index 00000000..0f259e13 --- /dev/null +++ b/docs/build.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +dot -Tpng "структура.dot" -o "структура.png" +pandoc -f markdown_strict --verbose "README.md" > "README.doc" diff --git a/docs/струкура-по.dot b/docs/струкура-по.dot new file mode 100644 index 00000000..5b3b6680 --- /dev/null +++ b/docs/струкура-по.dot @@ -0,0 +1,38 @@ +digraph "Прибор УФ" +{ + graph [nodesep=0.5, overlap = false]; + node [shape=box, overlap = false]; + eth0 [label="eth0", shape=cds]; + eth1 [label="eth1", shape=cds]; + eth2 [label="eth2", shape=cds]; + tty0 [label="tty0", shape=cds]; + tty1 [label="tty1", shape=cds]; + tty2 [label="tty2", shape=cds]; + proto_capsrpb [label="Протокол КАПС-РПБ"]; + proto_5p28 [label="Протокол 5П-28"]; + uarep_uart [label="UART ModBus"]; + uarep_ctl [label="ПО УАРЭП"]; + caps_rpb [label="СПО \"КАПС РПБ\""]; + p16 [label="П16", shape=plaintext, style=filled]; + spt25left [label="СПТ25 левый борт", shape=plaintext, style=filled]; + spt25right [label="СПТ25 правый борт", shape=plaintext, style=filled]; + + eth0 -> proto_5p28; + eth1 -> proto_5p28; + eth0 -> proto_capsrpb; + eth1 -> proto_capsrpb; + eth0 -> uarep_uart; + eth1 -> uarep_uart; + eth2 -> caps_rpb; + proto_5p28 -> uarep_ctl; + uarep_uart -> uarep_ctl; + uarep_uart -> tty0; + uarep_uart -> tty1; + uarep_uart -> tty2; + uarep_ctl -> caps_rpb; + proto_capsrpb -> caps_rpb; + p16 -> eth0 [style=invis] + p16 -> eth1 [style=invis] + spt25left -> tty0 [style=invis] + spt25right -> tty1 [style=invis] +} diff --git a/git-hook.sh b/git-hook.sh new file mode 100644 index 00000000..c59c0dfb --- /dev/null +++ b/git-hook.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Описание +# +# Этот скрипт генерирует модуль на языке GDScript с информацией для +# контроля версий в системе git. Использовать для проектов созданных +# для GoDot Engine. +# +# Инструкция по применению +# +# 1. В папке ./git/hooks создать файлы: +# - post-checkout +# - post-commit +# - post-merge +# 2. В каждый файл записать строку: +# ./git-hook.sh +# 3. Теперь модуль scripts/commit.gd будет содержать данные +# о текущей версии, после выполнения следующих операций СКВ: +# - переключение ветки; +# - выполнение фиксации; +# - выполнение слияния. + + +GDSCRIPT_FILE="scripts/commit.gd" +HEAD="$(git rev-parse --abbrev-ref HEAD) $(git rev-parse --short HEAD) $(git log -1 --format=%cd)" +echo "extends Node" > ${GDSCRIPT_FILE} +echo "# Файл сгенерирован автоматически! Не изменять вручную!" >> ${GDSCRIPT_FILE} +echo "const VCS_HEAD = '${HEAD}'" >> ${GDSCRIPT_FILE} diff --git a/modbus/client-serial-modbus.py b/modbus/client-serial-modbus.py new file mode 100644 index 00000000..c7573eab --- /dev/null +++ b/modbus/client-serial-modbus.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 + +""" +Драйвер клиента для интерфейса Modbus работающего через последовательный порт. +(приложение | UDP | JSON) <- (этот драйвер клиента) -> (сервер | Serial | Modbus RTU) +""" + +import os +import json +import argparse +import time +from socket import * + +import logger +from pymodbus.client.sync import ModbusSerialClient + +DEF_SERIAL = 'COM1' if os.name == 'nt' else '/dev/tty0' +log_file_dir = os.path.join(os.path.expanduser('~')) +log = logger.get_logger(__file__, log_file_dir) +EXCEPT_MSGS = { + 'write': 'ошибка: запись в регистры не выполнена', + 'read_holding': 'ошибка: чтение регистров хранения не выполнено', + 'read_input': 'ошибка: чтение регистров входов не выполнено', + '': 'ошибка: получена неверная команда' +} + + +def main(): + parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('-modbus-unit', default=1, type=int, help='Номер устройства Modbus') + parser.add_argument('-serial-baud', default=115200, type=int, help='Скорость последовательного порта, бод') + parser.add_argument('-serial-port', default=DEF_SERIAL, type=str, help='Последовательный порт для подключения к Modbus серверу') + parser.add_argument('-udp-addr', default='localhost', type=str, help='Адрес для отправки сообщений приложению') + parser.add_argument('-udp-port-rx', default=44100, type=int, help='Порт для приёма сообщений от приложения') + parser.add_argument('-udp-port-tx', default=48000, type=int, help='Порт для отправки сообщений приложению') + opts, opts_unk = parser.parse_known_args() + print_opts_error(opts_unk) + run_sync_client(opts.udp_addr, opts.udp_port_rx, opts.udp_port_tx, opts.serial_port, opts.serial_baud, opts.modbus_unit) + + +def run_sync_client(udp_addr, udp_port_rx, udp_port_tx, serial_port, serial_baud, unit_num): + sock = socket(AF_INET, SOCK_DGRAM) + sock.bind((udp_addr, udp_port_rx)) + log.debug(f'привязан к адресу {udp_addr}:{udp_port_rx}') + + json_decoder = json.JSONDecoder() + json_encoder = json.JSONEncoder() + + client = ModbusSerialClient(method='rtu', port=serial_port, baudrate=int(serial_baud)) + while not client.connect(): # Ждать подключения к серверу + time.sleep(3) + + log.debug(f'подключен к серверу через {serial_port} на скорости {serial_baud} бод') + + while True: + udp_data = sock.recv(2048) # Ждать команду от приложения + udp_data = udp_data.decode('utf-8') + message = json_decoder.decode(udp_data) # type: dict + + if 'code' not in message: + message['code'] = '' + + if 'data' not in message: + message['data'] = [] + + rc = False + if message['code'] == 'write': + data = message['data'] + rq = client.write_registers(data[0], data[1:], unit=unit_num) + rc = rq.isError() + elif message['code'] == 'read_holding': + data = message['data'] + rq = client.read_holding_registers(data[0], data[1:], unit=unit_num) + rc = rq.isError() + if not rc: + tx_data = json_encoder.encode({'code': 'response', 'data': rq.registers}) + sock.sendto(bytes(tx_data), (udp_addr, udp_port_tx)) + elif message['code'] == 'read_input': + data = message['data'] + rq = client.read_input_registers(data[0], data[1:], unit=unit_num) + rc = rq.isError() + if not rc: + tx_data = json_encoder.encode({'code': 'response', 'data': rq.registers}) + sock.sendto(bytes(tx_data), (udp_addr, udp_port_tx)) + elif message['code'] == '': + rc = False + + if rc: + code = message['code'] + data = message['data'] + rc_msg = EXCEPT_MSGS[code] + tx_data = json_encoder.encode({'code': 'exception', 'data': rc_msg}) + sock.sendto(bytes(tx_data), (udp_addr, udp_port_tx)) + log.debug( + 'ошибка при выполнении команды: \"%s\". данные: \"%s\", порт: \"%s\", скорость: %s' + % (code, data, serial_port, serial_baud)) + + +def print_opts_error(opts: list) -> None: + for opt in opts: + log.debug('внимание: неизвестный аргумент: \"%s\"\n' % opt) + + +if __name__ == '__main__': + main() diff --git a/modbus/logger.py b/modbus/logger.py new file mode 100644 index 00000000..548dd40c --- /dev/null +++ b/modbus/logger.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 + + +def get_logger(source_file, log_dir='logs'): + import logging + import os + source_file = os.path.split(source_file)[-1] + log_format = '[%(asctime)s] %(module)s:%(lineno)s %(message)s' + logging.basicConfig(format=log_format) + log = logging.getLogger() + log.setLevel(logging.DEBUG) + file_handler = logging.FileHandler(f'{log_dir}/{source_file}.log') + file_handler.setFormatter(logging.Formatter(log_format)) + log.addHandler(file_handler) + return log diff --git a/modbus/pymodbus/__init__.py b/modbus/pymodbus/__init__.py new file mode 100644 index 00000000..83199573 --- /dev/null +++ b/modbus/pymodbus/__init__.py @@ -0,0 +1,31 @@ +''' +Pymodbus: Modbus Protocol Implementation +----------------------------------------- + +TwistedModbus is built on top of the code developed by: + + Copyright (c) 2001-2005 S.W.A.C. GmbH, Germany. + Copyright (c) 2001-2005 S.W.A.C. Bohemia s.r.o., Czech Republic. + Hynek Petrak + +Released under the the BSD license +''' + +import pymodbus.version as __version +__version__ = __version.version.short() +__author__ = 'Galen Collins' +__maintainer__ = 'dhoomakethu' + +#---------------------------------------------------------------------------# +# Block unhandled logging +#---------------------------------------------------------------------------# +import logging as __logging +try: + from logging import NullHandler as __null +except ImportError: + class __null(__logging.Handler): + def emit(self, record): + pass + +__logging.getLogger(__name__).addHandler(__null()) + diff --git a/modbus/pymodbus/bit_read_message.py b/modbus/pymodbus/bit_read_message.py new file mode 100644 index 00000000..01434920 --- /dev/null +++ b/modbus/pymodbus/bit_read_message.py @@ -0,0 +1,247 @@ +""" +Bit Reading Request/Response messages +-------------------------------------- + +""" +import struct +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.utilities import pack_bitstring, unpack_bitstring +from pymodbus.compat import byte2int + + +class ReadBitsRequestBase(ModbusRequest): + ''' Base class for Messages Requesting bit values ''' + + _rtu_frame_size = 8 + + def __init__(self, address, count, **kwargs): + ''' Initializes the read request data + + :param address: The start address to read from + :param count: The number of bits after 'address' to read + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + self.count = count + + def encode(self): + ''' Encodes a request pdu + + :returns: The encoded pdu + ''' + return struct.pack('>HH', self.address, self.count) + + def decode(self, data): + ''' Decodes a request pdu + + :param data: The packet data to decode + ''' + self.address, self.count = struct.unpack('>HH', data) + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Byte Count(1 byte) + Quantity of Coils (n Bytes)/8, + if the remainder is different of 0 then N = N+1 + :return: + """ + count = self.count//8 + if self.count % 8: + count += 1 + + return 1 + 1 + count + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "ReadBitRequest(%d,%d)" % (self.address, self.count) + + +class ReadBitsResponseBase(ModbusResponse): + ''' Base class for Messages responding to bit-reading values ''' + + _rtu_byte_count_pos = 2 + + def __init__(self, values, **kwargs): + ''' Initializes a new instance + + :param values: The requested values to be returned + ''' + ModbusResponse.__init__(self, **kwargs) + self.bits = values or [] + + def encode(self): + ''' Encodes response pdu + + :returns: The encoded packet message + ''' + result = pack_bitstring(self.bits) + packet = struct.pack(">B", len(result)) + result + return packet + + def decode(self, data): + ''' Decodes response pdu + + :param data: The packet data to decode + ''' + self.byte_count = byte2int(data[0]) + self.bits = unpack_bitstring(data[1:]) + + def setBit(self, address, value=1): + ''' Helper function to set the specified bit + + :param address: The bit to set + :param value: The value to set the bit to + ''' + self.bits[address] = (value != 0) + + def resetBit(self, address): + ''' Helper function to set the specified bit to 0 + + :param address: The bit to reset + ''' + self.setBit(address, 0) + + def getBit(self, address): + ''' Helper function to get the specified bit's value + + :param address: The bit to query + :returns: The value of the requested bit + ''' + return self.bits[address] + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "%s(%d)" % (self.__class__.__name__, len(self.bits)) + + +class ReadCoilsRequest(ReadBitsRequestBase): + ''' + This function code is used to read from 1 to 2000(0x7d0) contiguous status + of coils in a remote device. The Request PDU specifies the starting + address, ie the address of the first coil specified, and the number of + coils. In the PDU Coils are addressed starting at zero. Therefore coils + numbered 1-16 are addressed as 0-15. + ''' + function_code = 1 + + def __init__(self, address=None, count=None, **kwargs): + ''' Initializes a new instance + + :param address: The address to start reading from + :param count: The number of bits to read + ''' + ReadBitsRequestBase.__init__(self, address, count, **kwargs) + + def execute(self, context): + ''' Run a read coils request against a datastore + + Before running the request, we make sure that the request is in + the max valid range (0x001-0x7d0). Next we make sure that the + request is valid against the current datastore. + + :param context: The datastore to request from + :returns: The initializes response message, exception message otherwise + ''' + if not (1 <= self.count <= 0x7d0): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = context.getValues(self.function_code, self.address, self.count) + return ReadCoilsResponse(values) + + +class ReadCoilsResponse(ReadBitsResponseBase): + ''' + The coils in the response message are packed as one coil per bit of + the data field. Status is indicated as 1= ON and 0= OFF. The LSB of the + first data byte contains the output addressed in the query. The other + coils follow toward the high order end of this byte, and from low order + to high order in subsequent bytes. + + If the returned output quantity is not a multiple of eight, the + remaining bits in the final data byte will be padded with zeros + (toward the high order end of the byte). The Byte Count field specifies + the quantity of complete bytes of data. + ''' + function_code = 1 + + def __init__(self, values=None, **kwargs): + ''' Intializes a new instance + + :param values: The request values to respond with + ''' + ReadBitsResponseBase.__init__(self, values, **kwargs) + + +class ReadDiscreteInputsRequest(ReadBitsRequestBase): + ''' + This function code is used to read from 1 to 2000(0x7d0) contiguous status + of discrete inputs in a remote device. The Request PDU specifies the + starting address, ie the address of the first input specified, and the + number of inputs. In the PDU Discrete Inputs are addressed starting at + zero. Therefore Discrete inputs numbered 1-16 are addressed as 0-15. + ''' + function_code = 2 + + def __init__(self, address=None, count=None, **kwargs): + ''' Intializes a new instance + + :param address: The address to start reading from + :param count: The number of bits to read + ''' + ReadBitsRequestBase.__init__(self, address, count, **kwargs) + + def execute(self, context): + ''' Run a read discrete input request against a datastore + + Before running the request, we make sure that the request is in + the max valid range (0x001-0x7d0). Next we make sure that the + request is valid against the current datastore. + + :param context: The datastore to request from + :returns: The initializes response message, exception message otherwise + ''' + if not (1 <= self.count <= 0x7d0): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = context.getValues(self.function_code, self.address, self.count) + return ReadDiscreteInputsResponse(values) + + +class ReadDiscreteInputsResponse(ReadBitsResponseBase): + ''' + The discrete inputs in the response message are packed as one input per + bit of the data field. Status is indicated as 1= ON; 0= OFF. The LSB of + the first data byte contains the input addressed in the query. The other + inputs follow toward the high order end of this byte, and from low order + to high order in subsequent bytes. + + If the returned input quantity is not a multiple of eight, the + remaining bits in the final data byte will be padded with zeros + (toward the high order end of the byte). The Byte Count field specifies + the quantity of complete bytes of data. + ''' + function_code = 2 + + def __init__(self, values=None, **kwargs): + ''' Intializes a new instance + + :param values: The request values to respond with + ''' + ReadBitsResponseBase.__init__(self, values, **kwargs) + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "ReadCoilsRequest", "ReadCoilsResponse", + "ReadDiscreteInputsRequest", "ReadDiscreteInputsResponse", +] diff --git a/modbus/pymodbus/bit_write_message.py b/modbus/pymodbus/bit_write_message.py new file mode 100644 index 00000000..2dfb6189 --- /dev/null +++ b/modbus/pymodbus/bit_write_message.py @@ -0,0 +1,270 @@ +""" +Bit Writing Request/Response +------------------------------ + +TODO write mask request/response +""" +import struct +from pymodbus.constants import ModbusStatus +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.utilities import pack_bitstring, unpack_bitstring + +#---------------------------------------------------------------------------# +# Local Constants +#---------------------------------------------------------------------------# +# These are defined in the spec to turn a coil on/off +#---------------------------------------------------------------------------# +_turn_coil_on = struct.pack(">H", ModbusStatus.On) +_turn_coil_off = struct.pack(">H", ModbusStatus.Off) + + +class WriteSingleCoilRequest(ModbusRequest): + ''' + This function code is used to write a single output to either ON or OFF + in a remote device. + + The requested ON/OFF state is specified by a constant in the request + data field. A value of FF 00 hex requests the output to be ON. A value + of 00 00 requests it to be OFF. All other values are illegal and will + not affect the output. + + The Request PDU specifies the address of the coil to be forced. Coils + are addressed starting at zero. Therefore coil numbered 1 is addressed + as 0. The requested ON/OFF state is specified by a constant in the Coil + Value field. A value of 0XFF00 requests the coil to be ON. A value of + 0X0000 requests the coil to be off. All other values are illegal and + will not affect the coil. + ''' + function_code = 5 + _rtu_frame_size = 8 + + def __init__(self, address=None, value=None, **kwargs): + ''' Initializes a new instance + + :param address: The variable address to write + :param value: The value to write at address + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + self.value = bool(value) + + def encode(self): + ''' Encodes write coil request + + :returns: The byte encoded message + ''' + result = struct.pack('>H', self.address) + if self.value: result += _turn_coil_on + else: result += _turn_coil_off + return result + + def decode(self, data): + ''' Decodes a write coil request + + :param data: The packet data to decode + ''' + self.address, value = struct.unpack('>HH', data) + self.value = (value == ModbusStatus.On) + + def execute(self, context): + ''' Run a write coil request against a datastore + + :param context: The datastore to request from + :returns: The populated response or exception message + ''' + #if self.value not in [ModbusStatus.Off, ModbusStatus.On]: + # return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, 1): + return self.doException(merror.IllegalAddress) + + context.setValues(self.function_code, self.address, [self.value]) + values = context.getValues(self.function_code, self.address, 1) + return WriteSingleCoilResponse(self.address, values[0]) + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Output Address (2 byte) + Output Value (2 Bytes) + :return: + """ + return 1 + 2 + 2 + + def __str__(self): + ''' Returns a string representation of the instance + + :return: A string representation of the instance + ''' + return "WriteCoilRequest(%d, %s) => " % (self.address, self.value) + + +class WriteSingleCoilResponse(ModbusResponse): + ''' + The normal response is an echo of the request, returned after the coil + state has been written. + ''' + function_code = 5 + _rtu_frame_size = 8 + + def __init__(self, address=None, value=None, **kwargs): + ''' Initializes a new instance + + :param address: The variable address written to + :param value: The value written at address + ''' + ModbusResponse.__init__(self, **kwargs) + self.address = address + self.value = value + + def encode(self): + ''' Encodes write coil response + + :return: The byte encoded message + ''' + result = struct.pack('>H', self.address) + if self.value: result += _turn_coil_on + else: result += _turn_coil_off + return result + + def decode(self, data): + ''' Decodes a write coil response + + :param data: The packet data to decode + ''' + self.address, value = struct.unpack('>HH', data) + self.value = (value == ModbusStatus.On) + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "WriteCoilResponse(%d) => %d" % (self.address, self.value) + + +class WriteMultipleCoilsRequest(ModbusRequest): + ''' + "This function code is used to force each coil in a sequence of coils to + either ON or OFF in a remote device. The Request PDU specifies the coil + references to be forced. Coils are addressed starting at zero. Therefore + coil numbered 1 is addressed as 0. + + The requested ON/OFF states are specified by contents of the request + data field. A logical '1' in a bit position of the field requests the + corresponding output to be ON. A logical '0' requests it to be OFF." + ''' + function_code = 15 + _rtu_byte_count_pos = 6 + + def __init__(self, address=None, values=None, **kwargs): + ''' Initializes a new instance + + :param address: The starting request address + :param values: The values to write + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + if not values: values = [] + elif not hasattr(values, '__iter__'): values = [values] + self.values = values + self.byte_count = (len(self.values) + 7) // 8 + + def encode(self): + ''' Encodes write coils request + + :returns: The byte encoded message + ''' + count = len(self.values) + self.byte_count = (count + 7) // 8 + packet = struct.pack('>HHB', self.address, count, self.byte_count) + packet += pack_bitstring(self.values) + return packet + + def decode(self, data): + ''' Decodes a write coils request + + :param data: The packet data to decode + ''' + self.address, count, self.byte_count = struct.unpack('>HHB', data[0:5]) + values = unpack_bitstring(data[5:]) + self.values = values[:count] + + def execute(self, context): + ''' Run a write coils request against a datastore + + :param context: The datastore to request from + :returns: The populated response or exception message + ''' + count = len(self.values) + if not (1 <= count <= 0x07b0): + return self.doException(merror.IllegalValue) + if (self.byte_count != (count + 7) // 8): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, count): + return self.doException(merror.IllegalAddress) + + context.setValues(self.function_code, self.address, self.values) + return WriteMultipleCoilsResponse(self.address, count) + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + params = (self.address, len(self.values)) + return "WriteNCoilRequest (%d) => %d " % params + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Output Address (2 byte) + Quantity of Outputs (2 Bytes) + :return: + """ + return 1 + 2 + 2 + + +class WriteMultipleCoilsResponse(ModbusResponse): + ''' + The normal response returns the function code, starting address, and + quantity of coils forced. + ''' + function_code = 15 + _rtu_frame_size = 8 + + def __init__(self, address=None, count=None, **kwargs): + ''' Initializes a new instance + + :param address: The starting variable address written to + :param count: The number of values written + ''' + ModbusResponse.__init__(self, **kwargs) + self.address = address + self.count = count + + def encode(self): + ''' Encodes write coils response + + :returns: The byte encoded message + ''' + return struct.pack('>HH', self.address, self.count) + + def decode(self, data): + ''' Decodes a write coils response + + :param data: The packet data to decode + ''' + self.address, self.count = struct.unpack('>HH', data) + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "WriteNCoilResponse(%d, %d)" % (self.address, self.count) + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "WriteSingleCoilRequest", "WriteSingleCoilResponse", + "WriteMultipleCoilsRequest", "WriteMultipleCoilsResponse", +] diff --git a/scenes/header.tscn b/modbus/pymodbus/client/__init__.py similarity index 100% rename from scenes/header.tscn rename to modbus/pymodbus/client/__init__.py diff --git a/modbus/pymodbus/client/asynchronous/__init__.py b/modbus/pymodbus/client/asynchronous/__init__.py new file mode 100644 index 00000000..b7d084de --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/__init__.py @@ -0,0 +1,43 @@ +""" +Async Modbus Client implementation based on Twisted, tornado and asyncio +------------------------------------------------------------------------ + +Example run:: + + from pymodbus.client.asynchronous import schedulers + + # Import The clients + + from pymodbus.client.asynchronous.tcp import AsyncModbusTCPClient as Client + from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient as Client + from pymodbus.client.asynchronous.udp import AsyncModbusUDPClient as Client + + # For tornado based asynchronous client use + event_loop, future = Client(schedulers.IO_LOOP, port=5020) + + # For twisted based asynchronous client use + event_loop, future = Client(schedulers.REACTOR, port=5020) + + # For asyncio based asynchronous client use + event_loop, client = Client(schedulers.ASYNC_IO, port=5020) + + # Here event_loop is a thread which would control the backend and future is + # a Future/deffered object which would be used to + # add call backs to run asynchronously. + + # The Actual client could be accessed with future.result() with Tornado + # and future.result when using twisted + + # For asyncio the actual client is returned and event loop is asyncio loop + +""" +from pymodbus.compat import is_installed + +installed = is_installed('twisted') +if installed: + # Import deprecated async client only if twisted is installed #338 + from pymodbus.client.asynchronous.deprecated.asynchronous import * + import logging + logger = logging.getLogger(__name__) + logger.warning("Importing deprecated clients. " + "Dependency Twisted is Installed") diff --git a/modbus/pymodbus/client/asynchronous/async_io/__init__.py b/modbus/pymodbus/client/asynchronous/async_io/__init__.py new file mode 100644 index 00000000..ff6af5c0 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/async_io/__init__.py @@ -0,0 +1,890 @@ +""" +Asynchronous framework adapter for asyncio. +""" +import socket +import asyncio +import functools +import ssl +from pymodbus.exceptions import ConnectionException +from pymodbus.client.asynchronous.mixins import AsyncModbusClientMixin +from pymodbus.utilities import hexlify_packets +from pymodbus.transaction import FifoTransactionManager +import logging + +_logger = logging.getLogger(__name__) + +DGRAM_TYPE = socket.SocketKind.SOCK_DGRAM + + +class BaseModbusAsyncClientProtocol(AsyncModbusClientMixin): + """ + Asyncio specific implementation of asynchronous modbus client protocol. + """ + + #: Factory that created this instance. + factory = None + transport = None + + async def execute(self, request=None): + """ + Executes requests asynchronously + :param request: + :return: + """ + req = self._execute(request) + resp = await asyncio.wait_for(req, timeout=self._timeout) + return resp + + def connection_made(self, transport): + """ + Called when a connection is made. + + The transport argument is the transport representing the connection. + :param transport: + :return: + """ + self.transport = transport + self._connectionMade() + + if self.factory: + self.factory.protocol_made_connection(self) + + def connection_lost(self, reason): + """ + Called when the connection is lost or closed. + + The argument is either an exception object or None + :param reason: + :return: + """ + self.transport = None + self._connectionLost(reason) + + if self.factory: + self.factory.protocol_lost_connection(self) + + def data_received(self, data): + """ + Called when some data is received. + data is a non-empty bytes object containing the incoming data. + :param data: + :return: + """ + self._dataReceived(data) + + def create_future(self): + """ + Helper function to create asyncio Future object + :return: + """ + return asyncio.Future() + + def resolve_future(self, f, result): + """ + Resolves the completed future and sets the result + :param f: + :param result: + :return: + """ + if not f.done(): + f.set_result(result) + + def raise_future(self, f, exc): + """ + Sets exception of a future if not done + :param f: + :param exc: + :return: + """ + if not f.done(): + f.set_exception(exc) + + def _connectionMade(self): + """ + Called upon a successful client connection. + """ + _logger.debug("Client connected to modbus server") + self._connected = True + + def _connectionLost(self, reason): + """ + Called upon a client disconnect + + :param reason: The reason for the disconnect + """ + _logger.debug( + "Client disconnected from modbus server: %s" % reason) + self._connected = False + for tid in list(self.transaction): + self.raise_future(self.transaction.getTransaction(tid), + ConnectionException( + 'Connection lost during request')) + + @property + def connected(self): + """ + Return connection status. + """ + return self._connected + + def write_transport(self, packet): + return self.transport.write(packet) + + def _execute(self, request, **kwargs): + """ + Starts the producer to send the next request to + consumer.write(Frame(request)) + """ + request.transaction_id = self.transaction.getNextTID() + packet = self.framer.buildPacket(request) + _logger.debug("send: " + hexlify_packets(packet)) + self.write_transport(packet) + return self._buildResponse(request.transaction_id) + + def _dataReceived(self, data): + ''' Get response, check for valid message, decode result + + :param data: The data returned from the server + ''' + _logger.debug("recv: " + hexlify_packets(data)) + unit = self.framer.decode_data(data).get("unit", 0) + self.framer.processIncomingPacket(data, self._handleResponse, unit=unit) + + def _handleResponse(self, reply, **kwargs): + """ + Handle the processed response and link to correct deferred + + :param reply: The reply to process + """ + if reply is not None: + tid = reply.transaction_id + handler = self.transaction.getTransaction(tid) + if handler: + self.resolve_future(handler, reply) + else: + _logger.debug("Unrequested message: " + str(reply)) + + def _buildResponse(self, tid): + """ + Helper method to return a deferred response + for the current request. + + :param tid: The transaction identifier for this response + :returns: A defer linked to the latest request + """ + f = self.create_future() + if not self._connected: + self.raise_future(f, ConnectionException( + 'Client is not connected')) + else: + self.transaction.addTransaction(f, tid) + return f + + def close(self): + self.transport.close() + self._connected = False + + +class ModbusClientProtocol(BaseModbusAsyncClientProtocol, asyncio.Protocol): + """ + Asyncio specific implementation of asynchronous modbus client protocol. + """ + + #: Factory that created this instance. + factory = None + transport = None + + def data_received(self, data): + """ + Called when some data is received. + data is a non-empty bytes object containing the incoming data. + :param data: + :return: + """ + self._dataReceived(data) + + +class ModbusUdpClientProtocol(BaseModbusAsyncClientProtocol, + asyncio.DatagramProtocol): + """ + Asyncio specific implementation of asynchronous modbus udp client protocol. + """ + + #: Factory that created this instance. + factory = None + + def __init__(self, host=None, port=0, **kwargs): + self.host = host + self.port = port + super(self.__class__, self).__init__(**kwargs) + + def datagram_received(self, data, addr): + self._dataReceived(data) + + def write_transport(self, packet): + return self.transport.sendto(packet) + + +class ReconnectingAsyncioModbusTcpClient(object): + """ + Client to connect to modbus device repeatedly over TCP/IP." + """ + #: Minimum delay in milli seconds before reconnect is attempted. + DELAY_MIN_MS = 100 + #: Maximum delay in milli seconds before reconnect is attempted. + DELAY_MAX_MS = 1000 * 60 * 5 + + def __init__(self, protocol_class=None, loop=None, **kwargs): + """ + Initialize ReconnectingAsyncioModbusTcpClient + :param protocol_class: Protocol used to talk to modbus device. + :param loop: Event loop to use + """ + #: Protocol used to talk to modbus device. + self.protocol_class = protocol_class or ModbusClientProtocol + #: Current protocol instance. + self.protocol = None + #: Event loop to use. + self.loop = loop or asyncio.get_event_loop() + self.host = None + self.port = 0 + self.connected = False + #: Reconnect delay in milli seconds. + self.delay_ms = self.DELAY_MIN_MS + self._proto_args = kwargs + + def reset_delay(self): + """ + Resets wait before next reconnect to minimal period. + """ + self.delay_ms = self.DELAY_MIN_MS + + @asyncio.coroutine + def start(self, host, port=502): + """ + Initiates connection to start client + :param host: + :param port: + :return: + """ + # force reconnect if required: + self.stop() + + _logger.debug('Connecting to %s:%s.' % (host, port)) + self.host = host + self.port = port + yield from self._connect() + + def stop(self): + """ + Stops client + :return: + """ + # prevent reconnect: + self.host = None + + if self.connected: + if self.protocol: + if self.protocol.transport: + self.protocol.transport.close() + + def _create_protocol(self): + """ + Factory function to create initialized protocol instance. + """ + protocol = self.protocol_class(**self._proto_args) + protocol.factory = self + return protocol + + @asyncio.coroutine + def _connect(self): + _logger.debug('Connecting.') + try: + yield from self.loop.create_connection(self._create_protocol, + self.host, + self.port) + except Exception as ex: + _logger.warning('Failed to connect: %s' % ex) + asyncio.ensure_future(self._reconnect(), loop=self.loop) + else: + _logger.info('Connected to %s:%s.' % (self.host, self.port)) + self.reset_delay() + + def protocol_made_connection(self, protocol): + """ + Protocol notification of successful connection. + """ + _logger.info('Protocol made connection.') + if not self.connected: + self.connected = True + self.protocol = protocol + else: + _logger.error('Factory protocol connect ' + 'callback called while connected.') + + def protocol_lost_connection(self, protocol): + """ + Protocol notification of lost connection. + """ + if self.connected: + _logger.info('Protocol lost connection.') + if protocol is not self.protocol: + _logger.error('Factory protocol callback called ' + 'from unexpected protocol instance.') + + self.connected = False + self.protocol = None + if self.host: + asyncio.ensure_future(self._reconnect(), loop=self.loop) + else: + _logger.error('Factory protocol disconnect callback called while not connected.') + + @asyncio.coroutine + def _reconnect(self): + _logger.debug('Waiting %d ms before next ' + 'connection attempt.' % self.delay_ms) + yield from asyncio.sleep(self.delay_ms / 1000) + self.delay_ms = min(2 * self.delay_ms, self.DELAY_MAX_MS) + yield from self._connect() + + +class AsyncioModbusTcpClient(object): + """Client to connect to modbus device over TCP/IP.""" + + def __init__(self, host=None, port=502, protocol_class=None, loop=None, **kwargs): + """ + Initializes Asyncio Modbus Tcp Client + :param host: Host IP address + :param port: Port to connect + :param protocol_class: Protocol used to talk to modbus device. + :param loop: Asyncio Event loop + """ + #: Protocol used to talk to modbus device. + self.protocol_class = protocol_class or ModbusClientProtocol + #: Current protocol instance. + self.protocol = None + #: Event loop to use. + self.loop = loop or asyncio.get_event_loop() + + self.host = host + self.port = port + + self.connected = False + self._proto_args = kwargs + + def stop(self): + """ + Stops the client + :return: + """ + if self.connected: + if self.protocol: + if self.protocol.transport: + self.protocol.transport.close() + + def _create_protocol(self): + """ + Factory function to create initialized protocol instance. + """ + protocol = self.protocol_class(**self._proto_args) + protocol.factory = self + return protocol + + @asyncio.coroutine + def connect(self): + """ + Connect and start Async client + :return: + """ + _logger.debug('Connecting.') + try: + yield from self.loop.create_connection(self._create_protocol, + self.host, + self.port) + _logger.info('Connected to %s:%s.' % (self.host, self.port)) + except Exception as ex: + _logger.warning('Failed to connect: %s' % ex) + # asyncio.asynchronous(self._reconnect(), loop=self.loop) + + def protocol_made_connection(self, protocol): + """ + Protocol notification of successful connection. + """ + _logger.info('Protocol made connection.') + if not self.connected: + self.connected = True + self.protocol = protocol + else: + _logger.error('Factory protocol connect ' + 'callback called while connected.') + + def protocol_lost_connection(self, protocol): + """ + Protocol notification of lost connection. + """ + if self.connected: + _logger.info('Protocol lost connection.') + if protocol is not self.protocol: + _logger.error('Factory protocol callback called' + ' from unexpected protocol instance.') + + self.connected = False + self.protocol = None + # if self.host: + # asyncio.asynchronous(self._reconnect(), loop=self.loop) + else: + _logger.error('Factory protocol disconnect' + ' callback called while not connected.') + + +class ReconnectingAsyncioModbusTlsClient(ReconnectingAsyncioModbusTcpClient): + """ + Client to connect to modbus device repeatedly over TLS." + """ + def __init__(self, protocol_class=None, loop=None, framer=None, **kwargs): + """ + Initialize ReconnectingAsyncioModbusTcpClient + :param protocol_class: Protocol used to talk to modbus device. + :param loop: Event loop to use + """ + self.framer = framer + ReconnectingAsyncioModbusTcpClient.__init__(self, protocol_class, loop, **kwargs) + + @asyncio.coroutine + def start(self, host, port=802, sslctx=None, server_hostname=None): + """ + Initiates connection to start client + :param host: + :param port: + :param sslctx: + :param server_hostname: + :return: + """ + self.sslctx = sslctx + if self.sslctx is None: + self.sslctx = ssl.create_default_context() + # According to MODBUS/TCP Security Protocol Specification, it is + # TLSv2 at least + self.sslctx.options |= ssl.OP_NO_TLSv1_1 + self.sslctx.options |= ssl.OP_NO_TLSv1 + self.sslctx.options |= ssl.OP_NO_SSLv3 + self.sslctx.options |= ssl.OP_NO_SSLv2 + self.server_hostname = server_hostname + yield from ReconnectingAsyncioModbusTcpClient.start(self, host, port) + + @asyncio.coroutine + def _connect(self): + _logger.debug('Connecting.') + try: + yield from self.loop.create_connection(self._create_protocol, + self.host, + self.port, + ssl=self.sslctx, + server_hostname=self.server_hostname) + except Exception as ex: + _logger.warning('Failed to connect: %s' % ex) + asyncio.ensure_future(self._reconnect(), loop=self.loop) + else: + _logger.info('Connected to %s:%s.' % (self.host, self.port)) + self.reset_delay() + + def _create_protocol(self): + """ + Factory function to create initialized protocol instance. + """ + protocol = self.protocol_class(framer=self.framer, **self._proto_args) + protocol.transaction = FifoTransactionManager(self) + protocol.factory = self + return protocol + +class ReconnectingAsyncioModbusUdpClient(object): + """ + Client to connect to modbus device repeatedly over UDP. + """ + + #: Reconnect delay in milli seconds. + delay_ms = 0 + + #: Maximum delay in milli seconds before reconnect is attempted. + DELAY_MAX_MS = 1000 * 60 * 5 + + def __init__(self, protocol_class=None, loop=None, **kwargs): + """ + Initializes ReconnectingAsyncioModbusUdpClient + :param protocol_class: Protocol used to talk to modbus device. + :param loop: Asyncio Event loop + """ + #: Protocol used to talk to modbus device. + self.protocol_class = protocol_class or ModbusUdpClientProtocol + #: Current protocol instance. + self.protocol = None + #: Event loop to use. + self.loop = loop or asyncio.get_event_loop() + + self.host = None + self.port = 0 + + self.connected = False + self._proto_args = kwargs + self.reset_delay() + + def reset_delay(self): + """ + Resets wait before next reconnect to minimal period. + """ + self.delay_ms = 100 + + @asyncio.coroutine + def start(self, host, port=502): + """ + Start reconnecting asynchronous udp client + :param host: Host IP to connect + :param port: Host port to connect + :return: + """ + # force reconnect if required: + self.stop() + + _logger.debug('Connecting to %s:%s.' % (host, port)) + + # getaddrinfo returns a list of tuples + # - [(family, type, proto, canonname, sockaddr),] + # We want sockaddr which is a (ip, port) tuple + # udp needs ip addresses, not hostnames + addrinfo = yield from self.loop.getaddrinfo(host, + port, + type=DGRAM_TYPE) + self.host, self.port = addrinfo[0][-1] + + yield from self._connect() + + def stop(self): + """ + Stops connection and prevents reconnect + :return: + """ + # prevent reconnect: + self.host = None + + if self.connected: + if self.protocol: + if self.protocol.transport: + self.protocol.transport.close() + + def _create_protocol(self, host=None, port=0): + """ + Factory function to create initialized protocol instance. + """ + protocol = self.protocol_class(**self._proto_args) + protocol.host = host + protocol.port = port + protocol.factory = self + return protocol + + @asyncio.coroutine + def _connect(self): + _logger.debug('Connecting.') + try: + yield from self.loop.create_datagram_endpoint( + functools.partial(self._create_protocol, + host=self.host, + port=self.port), + remote_addr=(self.host, self.port) + ) + _logger.info('Connected to %s:%s.' % (self.host, self.port)) + except Exception as ex: + _logger.warning('Failed to connect: %s' % ex) + asyncio.ensure_future(self._reconnect(), loop=self.loop) + + def protocol_made_connection(self, protocol): + """ + Protocol notification of successful connection. + """ + _logger.info('Protocol made connection.') + if not self.connected: + self.connected = True + self.protocol = protocol + else: + _logger.error('Factory protocol connect callback ' + 'called while connected.') + + def protocol_lost_connection(self, protocol): + """ + Protocol notification of lost connection. + """ + if self.connected: + _logger.info('Protocol lost connection.') + if protocol is not self.protocol: + _logger.error('Factory protocol callback called ' + 'from unexpected protocol instance.') + + self.connected = False + self.protocol = None + if self.host: + asyncio.ensure_future(self._reconnect(), loop=self.loop) + else: + _logger.error('Factory protocol disconnect ' + 'callback called while not connected.') + + @asyncio.coroutine + def _reconnect(self): + _logger.debug('Waiting %d ms before next ' + 'connection attempt.' % self.delay_ms) + yield from asyncio.sleep(self.delay_ms / 1000) + self.delay_ms = min(2 * self.delay_ms, self.DELAY_MAX_MS) + yield from self._connect() + + +class AsyncioModbusUdpClient(object): + """ + Client to connect to modbus device over UDP. + """ + + def __init__(self, host=None, port=502, protocol_class=None, loop=None, **kwargs): + """ + Initializes Asyncio Modbus UDP Client + :param host: Host IP address + :param port: Port to connect + :param protocol_class: Protocol used to talk to modbus device. + :param loop: Asyncio Event loop + """ + #: Protocol used to talk to modbus device. + self.protocol_class = protocol_class or ModbusUdpClientProtocol + #: Current protocol instance. + self.protocol = None + #: Event loop to use. + self.loop = loop or asyncio.get_event_loop() + + self.host = host + self.port = port + + self.connected = False + self._proto_args = kwargs + + def stop(self): + """ + Stops connection + :return: + """ + # prevent reconnect: + # self.host = None + + if self.connected: + if self.protocol: + if self.protocol.transport: + self.protocol.transport.close() + + def _create_protocol(self, host=None, port=0): + """ + Factory function to create initialized protocol instance. + """ + protocol = self.protocol_class(**self._proto_args) + protocol.host = host + protocol.port = port + protocol.factory = self + return protocol + + @asyncio.coroutine + def connect(self): + _logger.debug('Connecting.') + try: + addrinfo = yield from self.loop.getaddrinfo( + self.host, + self.port, + type=DGRAM_TYPE) + _host, _port = addrinfo[0][-1] + yield from self.loop.create_datagram_endpoint( + functools.partial(self._create_protocol, + host=_host, port=_port), + remote_addr=(self.host, self.port) + ) + _logger.info('Connected to %s:%s.' % (self.host, self.port)) + except Exception as ex: + _logger.warning('Failed to connect: %s' % ex) + # asyncio.asynchronous(self._reconnect(), loop=self.loop) + + def protocol_made_connection(self, protocol): + """ + Protocol notification of successful connection. + """ + _logger.info('Protocol made connection.') + if not self.connected: + self.connected = True + self.protocol = protocol + else: + _logger.error('Factory protocol connect ' + 'callback called while connected.') + + def protocol_lost_connection(self, protocol): + """ + Protocol notification of lost connection. + """ + if self.connected: + _logger.info('Protocol lost connection.') + if protocol is not self.protocol: + _logger.error('Factory protocol callback ' + 'called from unexpected protocol instance.') + + self.connected = False + self.protocol = None + # if self.host: + # asyncio.asynchronous(self._reconnect(), loop=self.loop) + else: + _logger.error('Factory protocol disconnect ' + 'callback called while not connected.') + + +class AsyncioModbusSerialClient(object): + """ + Client to connect to modbus device over serial. + """ + transport = None + framer = None + + def __init__(self, port, protocol_class=None, framer=None, loop=None, + baudrate=9600, bytesize=8, parity='N', stopbits=1, **serial_kwargs): + """ + Initializes Asyncio Modbus Serial Client + :param port: Port to connect + :param protocol_class: Protocol used to talk to modbus device. + :param framer: Framer to use + :param loop: Asyncio Event loop + """ + #: Protocol used to talk to modbus device. + self.protocol_class = protocol_class or ModbusClientProtocol + #: Current protocol instance. + self.protocol = None + #: Event loop to use. + self.loop = loop or asyncio.get_event_loop() + self.port = port + self.baudrate = baudrate + self.bytesize = bytesize + self.parity = parity + self.stopbits = stopbits + self.framer = framer + self._extra_serial_kwargs = serial_kwargs + self._connected_event = asyncio.Event() + + def stop(self): + """ + Stops connection + :return: + """ + if self._connected: + if self.protocol: + if self.protocol.transport: + self.protocol.transport.close() + + def _create_protocol(self): + protocol = self.protocol_class(framer=self.framer) + protocol.factory = self + return protocol + + @property + def _connected(self): + return self._connected_event.is_set() + + @asyncio.coroutine + def connect(self): + """ + Connect Async client + :return: + """ + _logger.debug('Connecting.') + try: + from serial_asyncio import create_serial_connection + + yield from create_serial_connection( + self.loop, self._create_protocol, self.port, baudrate=self.baudrate, + bytesize=self.bytesize, stopbits=self.stopbits, parity=self.parity, **self._extra_serial_kwargs + ) + yield from self._connected_event.wait() + _logger.info('Connected to %s', self.port) + except Exception as ex: + _logger.warning('Failed to connect: %s', ex) + + def protocol_made_connection(self, protocol): + """ + Protocol notification of successful connection. + """ + _logger.info('Protocol made connection.') + if not self._connected: + self._connected_event.set() + self.protocol = protocol + else: + _logger.error('Factory protocol connect ' + 'callback called while connected.') + + def protocol_lost_connection(self, protocol): + """ + Protocol notification of lost connection. + """ + if self._connected: + _logger.info('Protocol lost connection.') + if protocol is not self.protocol: + _logger.error('Factory protocol callback called' + ' from unexpected protocol instance.') + + self._connected_event.clear() + self.protocol = None + # if self.host: + # asyncio.asynchronous(self._reconnect(), loop=self.loop) + else: + _logger.error('Factory protocol disconnect callback ' + 'called while not connected.') + + +@asyncio.coroutine +def init_tcp_client(proto_cls, loop, host, port, **kwargs): + """ + Helper function to initialize tcp client + :param proto_cls: + :param loop: + :param host: + :param port: + :param kwargs: + :return: + """ + client = ReconnectingAsyncioModbusTcpClient(protocol_class=proto_cls, + loop=loop, **kwargs) + yield from client.start(host, port) + return client + + +@asyncio.coroutine +def init_tls_client(proto_cls, loop, host, port, sslctx=None, + server_hostname=None, framer=None, **kwargs): + """ + Helper function to initialize tcp client + :param proto_cls: + :param loop: + :param host: + :param port: + :param sslctx: + :param server_hostname: + :param framer: + :param kwargs: + :return: + """ + client = ReconnectingAsyncioModbusTlsClient(protocol_class=proto_cls, + loop=loop, framer=framer, + **kwargs) + yield from client.start(host, port, sslctx, server_hostname) + return client + + +@asyncio.coroutine +def init_udp_client(proto_cls, loop, host, port, **kwargs): + """ + Helper function to initialize UDP client + :param proto_cls: + :param loop: + :param host: + :param port: + :param kwargs: + :return: + """ + client = ReconnectingAsyncioModbusUdpClient(protocol_class=proto_cls, + loop=loop, **kwargs) + yield from client.start(host, port) + return client diff --git a/modbus/pymodbus/client/asynchronous/deprecated/__init__.py b/modbus/pymodbus/client/asynchronous/deprecated/__init__.py new file mode 100644 index 00000000..d891071e --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/deprecated/__init__.py @@ -0,0 +1,47 @@ +import warnings +warnings.simplefilter('always', DeprecationWarning) + +WARNING = """ +Usage of '{}' is deprecated from 2.0.0 and will be removed in future releases. +Use the new Async Modbus Client implementation based on Twisted, tornado +and asyncio +------------------------------------------------------------------------ + +Example run:: + + from pymodbus.client.asynchronous import schedulers + + # Import The clients + + from pymodbus.client.asynchronous.tcp import AsyncModbusTCPClient as Client + from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient as Client + from pymodbus.client.asynchronous.udp import AsyncModbusUDPClient as Client + + # For tornado based asynchronous client use + event_loop, future = Client(schedulers.IO_LOOP, port=5020) + + # For twisted based asynchronous client use + event_loop, deferred = Client(schedulers.REACTOR, port=5020) + + # For asyncio based asynchronous client use + event_loop, client = Client(schedulers.ASYNC_IO, port=5020) + + # Here event_loop is a thread which would control the backend and future is + # a Future/deffered object which would be used to + # add call backs to run asynchronously. + + # The Actual client could be accessed with future.result() with Tornado + # and future.result when using twisted + + # For asyncio the actual client is returned and event loop is asyncio loop + +Refer: +https://pymodbus.readthedocs.io/en/dev/source/example/async_twisted_client.html +https://pymodbus.readthedocs.io/en/dev/source/example/async_tornado_client.html +https://pymodbus.readthedocs.io/en/dev/source/example/async_asyncio_client.html + +""" + + +def deprecated(name): # pragma: no cover + warnings.warn(WARNING.format(name), DeprecationWarning) diff --git a/modbus/pymodbus/client/asynchronous/deprecated/asynchronous.py b/modbus/pymodbus/client/asynchronous/deprecated/asynchronous.py new file mode 100644 index 00000000..bd948ae7 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/deprecated/asynchronous.py @@ -0,0 +1,231 @@ +""" +Implementation of a Modbus Client Using Twisted +-------------------------------------------------- + +Example run:: + + from twisted.internet import reactor, protocol + from pymodbus.client.asynchronous import ModbusClientProtocol + + def printResult(result): + print "Result: %d" % result.bits[0] + + def process(client): + result = client.write_coil(1, True) + result.addCallback(printResult) + reactor.callLater(1, reactor.stop) + + defer = protocol.ClientCreator(reactor, ModbusClientProtocol + ).connectTCP("localhost", 502) + defer.addCallback(process) + +Another example:: + + from twisted.internet import reactor + from pymodbus.client.asynchronous import ModbusClientFactory + + def process(): + factory = reactor.connectTCP("localhost", 502, ModbusClientFactory()) + reactor.stop() + + if __name__ == "__main__": + reactor.callLater(1, process) + reactor.run() +""" +import logging +from pymodbus.factory import ClientDecoder +from pymodbus.exceptions import ConnectionException +from pymodbus.transaction import ModbusSocketFramer +from pymodbus.transaction import FifoTransactionManager +from pymodbus.transaction import DictTransactionManager +from pymodbus.client.common import ModbusClientMixin +from pymodbus.client.asynchronous.deprecated import deprecated +from twisted.internet import defer, protocol +from twisted.python.failure import Failure + + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Connected Client Protocols +# --------------------------------------------------------------------------- # +class ModbusClientProtocol(protocol.Protocol, ModbusClientMixin): # pragma: no cover + """ + This represents the base modbus client protocol. All the application + layer code is deferred to a higher level wrapper. + """ + + def __init__(self, framer=None, **kwargs): + """ Initializes the framer module + + :param framer: The framer to use for the protocol + """ + deprecated(self.__class__.__name__) + self._connected = False + self.framer = framer or ModbusSocketFramer(ClientDecoder()) + if isinstance(self.framer, type): + # Framer class not instance + self.framer = self.framer(ClientDecoder(), client=None) + if isinstance(self.framer, ModbusSocketFramer): + self.transaction = DictTransactionManager(self, **kwargs) + else: + self.transaction = FifoTransactionManager(self, **kwargs) + + def connectionMade(self): + """ Called upon a successful client connection. + """ + _logger.debug("Client connected to modbus server") + self._connected = True + + def connectionLost(self, reason): + """ Called upon a client disconnect + + :param reason: The reason for the disconnect + """ + _logger.debug("Client disconnected from modbus server: %s" % reason) + self._connected = False + for tid in list(self.transaction): + self.transaction.getTransaction(tid).errback(Failure( + ConnectionException('Connection lost during request'))) + + def dataReceived(self, data): + """ Get response, check for valid message, decode result + + :param data: The data returned from the server + """ + unit = self.framer.decode_data(data).get("uid", 0) + self.framer.processIncomingPacket(data, self._handleResponse, unit=unit) + + def execute(self, request): + """ Starts the producer to send the next request to + consumer.write(Frame(request)) + """ + request.transaction_id = self.transaction.getNextTID() + packet = self.framer.buildPacket(request) + self.transport.write(packet) + return self._buildResponse(request.transaction_id) + + def _handleResponse(self, reply): + """ Handle the processed response and link to correct deferred + + :param reply: The reply to process + """ + if reply is not None: + tid = reply.transaction_id + handler = self.transaction.getTransaction(tid) + if handler: + handler.callback(reply) + else: + _logger.debug("Unrequested message: " + str(reply)) + + def _buildResponse(self, tid): + """ Helper method to return a deferred response + for the current request. + + :param tid: The transaction identifier for this response + :returns: A defer linked to the latest request + """ + if not self._connected: + return defer.fail(Failure( + ConnectionException('Client is not connected'))) + + d = defer.Deferred() + self.transaction.addTransaction(d, tid) + return d + + # ---------------------------------------------------------------------- # + # Extra Functions + # ---------------------------------------------------------------------- # + # if send_failed: + # if self.retry > 0: + # deferLater(clock, self.delay, send, message) + # self.retry -= 1 + + +# --------------------------------------------------------------------------- # +# Not Connected Client Protocol +# --------------------------------------------------------------------------- # +class ModbusUdpClientProtocol(protocol.DatagramProtocol, ModbusClientMixin): # pragma: no cover + """ + This represents the base modbus client protocol. All the application + layer code is deferred to a higher level wrapper. + """ + + def __init__(self, framer=None, **kwargs): + """ Initializes the framer module + + :param framer: The framer to use for the protocol + """ + deprecated(self.__class__.__name__) + self.framer = framer or ModbusSocketFramer(ClientDecoder()) + if isinstance(self.framer, ModbusSocketFramer): + self.transaction = DictTransactionManager(self, **kwargs) + else: self.transaction = FifoTransactionManager(self, **kwargs) + + def datagramReceived(self, data, params): + """ Get response, check for valid message, decode result + + :param data: The data returned from the server + :param params: The host parameters sending the datagram + """ + _logger.debug("Datagram from: %s:%d" % params) + unit = self.framer.decode_data(data).get("uid", 0) + self.framer.processIncomingPacket(data, self._handleResponse, unit=unit) + + def execute(self, request): + """ Starts the producer to send the next request to + consumer.write(Frame(request)) + """ + request.transaction_id = self.transaction.getNextTID() + packet = self.framer.buildPacket(request) + self.transport.write(packet) + return self._buildResponse(request.transaction_id) + + def _handleResponse(self, reply): + """ Handle the processed response and link to correct deferred + + :param reply: The reply to process + """ + if reply is not None: + tid = reply.transaction_id + handler = self.transaction.getTransaction(tid) + if handler: + handler.callback(reply) + else: _logger.debug("Unrequested message: " + str(reply)) + + def _buildResponse(self, tid): + """ Helper method to return a deferred response + for the current request. + + :param tid: The transaction identifier for this response + :returns: A defer linked to the latest request + """ + d = defer.Deferred() + self.transaction.addTransaction(d, tid) + return d + + +# --------------------------------------------------------------------------- # +# Client Factories +# --------------------------------------------------------------------------- # +class ModbusClientFactory(protocol.ReconnectingClientFactory): # pragma: no cover + """ Simple client protocol factory """ + + protocol = ModbusClientProtocol + + def __init__(self): + deprecated(self.__class__.__name__) + protocol.ReconnectingClientFactory.__init__(self) + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + + +__all__ = [ + "ModbusClientProtocol", "ModbusUdpClientProtocol", "ModbusClientFactory" +] diff --git a/modbus/pymodbus/client/asynchronous/factory/__init__.py b/modbus/pymodbus/client/asynchronous/factory/__init__.py new file mode 100644 index 00000000..5a456ce8 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/factory/__init__.py @@ -0,0 +1 @@ +from __future__ import absolute_import, unicode_literals \ No newline at end of file diff --git a/modbus/pymodbus/client/asynchronous/factory/serial.py b/modbus/pymodbus/client/asynchronous/factory/serial.py new file mode 100644 index 00000000..62b719c5 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/factory/serial.py @@ -0,0 +1,130 @@ +""" +Factory to create asynchronous serial clients based on twisted/tornado/asyncio +""" +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging +import time +from pymodbus.client.asynchronous import schedulers +from pymodbus.client.asynchronous.thread import EventLoopThread + +LOGGER = logging.getLogger(__name__) + + +def reactor_factory(port, framer, **kwargs): + """ + Factory to create twisted serial asynchronous client + :param port: Serial port + :param framer: Modbus Framer + :param kwargs: + :return: event_loop_thread and twisted serial client + """ + from twisted.internet import reactor + from twisted.internet.serialport import SerialPort + from twisted.internet.protocol import ClientFactory + from pymodbus.factory import ClientDecoder + + class SerialClientFactory(ClientFactory): + def __init__(self, framer, proto_cls): + ''' Remember things necessary for building a protocols ''' + self.proto_cls = proto_cls + self.framer = framer + + def buildProtocol(self): + ''' Create a protocol and start the reading cycle ''' + proto = self.proto_cls(self.framer) + proto.factory = self + return proto + + class SerialModbusClient(SerialPort): + + def __init__(self, framer, *args, **kwargs): + ''' Setup the client and start listening on the serial port + + :param factory: The factory to build clients with + ''' + self.decoder = ClientDecoder() + proto_cls = kwargs.pop("proto_cls", None) + proto = SerialClientFactory(framer, proto_cls).buildProtocol() + SerialPort.__init__(self, proto, *args, **kwargs) + + proto = EventLoopThread("reactor", reactor.run, reactor.stop, + installSignalHandlers=0) + ser_client = SerialModbusClient(framer, port, reactor, **kwargs) + + return proto, ser_client + + +def io_loop_factory(port=None, framer=None, **kwargs): + """ + Factory to create Tornado based asynchronous serial clients + :param port: Serial port + :param framer: Modbus Framer + :param kwargs: + :return: event_loop_thread and tornado future + """ + + from tornado.ioloop import IOLoop + from pymodbus.client.asynchronous.tornado import (AsyncModbusSerialClient as + Client) + + ioloop = IOLoop() + protocol = EventLoopThread("ioloop", ioloop.start, ioloop.stop) + protocol.start() + client = Client(port=port, framer=framer, ioloop=ioloop, **kwargs) + + future = client.connect() + + return protocol, future + + +def async_io_factory(port=None, framer=None, **kwargs): + """ + Factory to create asyncio based asynchronous serial clients + :param port: Serial port + :param framer: Modbus Framer + :param kwargs: Serial port options + :return: asyncio event loop and serial client + """ + import asyncio + from pymodbus.client.asynchronous.async_io import (ModbusClientProtocol, + AsyncioModbusSerialClient) + loop = kwargs.pop("loop", None) or asyncio.get_event_loop() + proto_cls = kwargs.pop("proto_cls", None) or ModbusClientProtocol + + try: + from serial_asyncio import create_serial_connection + except ImportError: + LOGGER.critical("pyserial-asyncio is not installed, " + "install with 'pip install pyserial-asyncio") + import sys + sys.exit(1) + + client = AsyncioModbusSerialClient(port, proto_cls, framer, loop, **kwargs) + coro = client.connect() + if loop.is_running(): + future = asyncio.run_coroutine_threadsafe(coro, loop=loop) + future.result() + else: + loop.run_until_complete(coro) + return loop, client + + +def get_factory(scheduler): + """ + Gets protocol factory based on the backend scheduler being used + :param scheduler: REACTOR/IO_LOOP/ASYNC_IO + :return: + """ + if scheduler == schedulers.REACTOR: + return reactor_factory + elif scheduler == schedulers.IO_LOOP: + return io_loop_factory + elif scheduler == schedulers.ASYNC_IO: + return async_io_factory + else: + LOGGER.warning("Allowed Schedulers: {}, {}, {}".format( + schedulers.REACTOR, schedulers.IO_LOOP, schedulers.ASYNC_IO + )) + raise Exception("Invalid Scheduler '{}'".format(scheduler)) diff --git a/modbus/pymodbus/client/asynchronous/factory/tcp.py b/modbus/pymodbus/client/asynchronous/factory/tcp.py new file mode 100644 index 00000000..2509cadc --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/factory/tcp.py @@ -0,0 +1,123 @@ +""" +Factory to create asynchronous tcp clients based on twisted/tornado/asyncio +""" +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging + +from pymodbus.client.asynchronous import schedulers +from pymodbus.client.asynchronous.thread import EventLoopThread +from pymodbus.constants import Defaults + +LOGGER = logging.getLogger(__name__) + + +def reactor_factory(host="127.0.0.1", port=Defaults.Port, framer=None, + source_address=None, timeout=None, **kwargs): + """ + Factory to create twisted tcp asynchronous client + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer + :param source_address: Bind address + :param timeout: Timeout in seconds + :param kwargs: + :return: event_loop_thread and twisted_deferred + """ + from twisted.internet import reactor, protocol + from pymodbus.client.asynchronous.twisted import ModbusTcpClientProtocol + + deferred = protocol.ClientCreator( + reactor, ModbusTcpClientProtocol + ).connectTCP(host, port, timeout=timeout, bindAddress=source_address) + + callback = kwargs.get("callback") + errback = kwargs.get("errback") + + if callback: + deferred.addCallback(callback) + + if errback: + deferred.addErrback(errback) + + protocol = EventLoopThread("reactor", reactor.run, reactor.stop, + installSignalHandlers=0) + protocol.start() + + return protocol, deferred + + +def io_loop_factory(host="127.0.0.1", port=Defaults.Port, framer=None, + source_address=None, timeout=None, **kwargs): + """ + Factory to create Tornado based asynchronous tcp clients + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer + :param source_address: Bind address + :param timeout: Timeout in seconds + :param kwargs: + :return: event_loop_thread and tornado future + """ + from tornado.ioloop import IOLoop + from pymodbus.client.asynchronous.tornado import AsyncModbusTCPClient as \ + Client + + ioloop = IOLoop() + protocol = EventLoopThread("ioloop", ioloop.start, ioloop.stop) + protocol.start() + + client = Client(host=host, port=port, framer=framer, + source_address=source_address, + timeout=timeout, ioloop=ioloop, **kwargs) + + future = client.connect() + + return protocol, future + + +def async_io_factory(host="127.0.0.1", port=Defaults.Port, **kwargs): + """ + Factory to create asyncio based asynchronous tcp clients + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer + :param source_address: Bind address + :param timeout: Timeout in seconds + :param kwargs: + :return: asyncio event loop and tcp client + """ + import asyncio + from pymodbus.client.asynchronous.async_io import init_tcp_client + loop = kwargs.pop("loop", None) or asyncio.new_event_loop() + proto_cls = kwargs.pop("proto_cls", None) + if not loop.is_running(): + asyncio.set_event_loop(loop) + cor = init_tcp_client(proto_cls, loop, host, port, **kwargs) + client = loop.run_until_complete(asyncio.gather(cor))[0] + else: + cor = init_tcp_client(proto_cls, loop, host, port, **kwargs) + future = asyncio.run_coroutine_threadsafe(cor, loop=loop) + client = future.result() + + return loop, client + + +def get_factory(scheduler): + """ + Gets protocol factory based on the backend scheduler being used + :param scheduler: REACTOR/IO_LOOP/ASYNC_IO + :return + """ + if scheduler == schedulers.REACTOR: + return reactor_factory + elif scheduler == schedulers.IO_LOOP: + return io_loop_factory + elif scheduler == schedulers.ASYNC_IO: + return async_io_factory + else: + LOGGER.warning("Allowed Schedulers: {}, {}, {}".format( + schedulers.REACTOR, schedulers.IO_LOOP, schedulers.ASYNC_IO + )) + raise Exception("Invalid Scheduler '{}'".format(scheduler)) diff --git a/modbus/pymodbus/client/asynchronous/factory/tls.py b/modbus/pymodbus/client/asynchronous/factory/tls.py new file mode 100644 index 00000000..652bec7f --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/factory/tls.py @@ -0,0 +1,59 @@ +""" +Factory to create asynchronous tls clients based on asyncio +""" +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging + +from pymodbus.client.asynchronous import schedulers +from pymodbus.client.asynchronous.thread import EventLoopThread +from pymodbus.constants import Defaults + +LOGGER = logging.getLogger(__name__) + +def async_io_factory(host="127.0.0.1", port=Defaults.TLSPort, sslctx=None, + server_hostname=None, framer=None, **kwargs): + """ + Factory to create asyncio based asynchronous tls clients + :param host: Host IP address + :param port: Port + :param sslctx: The SSLContext to use for TLS (default None and auto create) + :param server_hostname: Target server's name matched for certificate + :param framer: Modbus Framer + :param source_address: Bind address + :param timeout: Timeout in seconds + :param kwargs: + :return: asyncio event loop and tcp client + """ + import asyncio + from pymodbus.client.asynchronous.async_io import init_tls_client + loop = kwargs.pop("loop", None) or asyncio.new_event_loop() + proto_cls = kwargs.pop("proto_cls", None) + if not loop.is_running(): + asyncio.set_event_loop(loop) + cor = init_tls_client(proto_cls, loop, host, port, sslctx, server_hostname, + framer, **kwargs) + client = loop.run_until_complete(asyncio.gather(cor))[0] + else: + cor = init_tls_client(proto_cls, loop, host, port, sslctx, server_hostname, + framer, **kwargs) + future = asyncio.run_coroutine_threadsafe(cor, loop=loop) + client = future.result() + + return loop, client + + +def get_factory(scheduler): + """ + Gets protocol factory based on the backend scheduler being used + :param scheduler: ASYNC_IO + :return + """ + if scheduler == schedulers.ASYNC_IO: + return async_io_factory + else: + LOGGER.warning("Allowed Schedulers: {}".format( + schedulers.ASYNC_IO + )) + raise Exception("Invalid Scheduler '{}'".format(scheduler)) diff --git a/modbus/pymodbus/client/asynchronous/factory/udp.py b/modbus/pymodbus/client/asynchronous/factory/udp.py new file mode 100644 index 00000000..0745eb51 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/factory/udp.py @@ -0,0 +1,96 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging + +from pymodbus.client.asynchronous import schedulers +from pymodbus.client.asynchronous.thread import EventLoopThread +from pymodbus.constants import Defaults + +LOGGER = logging.getLogger(__name__) + + +def reactor_factory(host="127.0.0.1", port=Defaults.Port, framer=None, + source_address=None, timeout=None, **kwargs): + """ + Factory to create twisted udp asynchronous client + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer + :param source_address: Bind address + :param timeout: Timeout in seconds + :param kwargs: + :return: event_loop_thread and twisted_deferred + """ + raise NotImplementedError() + + +def io_loop_factory(host="127.0.0.1", port=Defaults.Port, framer=None, + source_address=None, timeout=None, **kwargs): + """ + Factory to create Tornado based asynchronous udp clients + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer + :param source_address: Bind address + :param timeout: Timeout in seconds + :param kwargs: + :return: event_loop_thread and tornado future + """ + from tornado.ioloop import IOLoop + from pymodbus.client.asynchronous.tornado import AsyncModbusUDPClient as \ + Client + + client = Client(host=host, port=port, framer=framer, + source_address=source_address, + timeout=timeout, **kwargs) + protocol = EventLoopThread("ioloop", IOLoop.current().start, + IOLoop.current().stop) + protocol.start() + future = client.connect() + + return protocol, future + + +def async_io_factory(host="127.0.0.1", port=Defaults.Port, **kwargs): + """ + Factory to create asyncio based asynchronous udp clients + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer + :param source_address: Bind address + :param timeout: Timeout in seconds + :param kwargs: + :return: asyncio event loop and udp client + """ + import asyncio + from pymodbus.client.asynchronous.async_io import init_udp_client + loop = kwargs.pop("loop", None) or asyncio.get_event_loop() + proto_cls = kwargs.pop("proto_cls", None) + cor = init_udp_client(proto_cls, loop, host, port, **kwargs) + if not loop.is_running(): + client = loop.run_until_complete(asyncio.gather(cor))[0] + else: + client = asyncio.run_coroutine_threadsafe(cor, loop=loop) + client = client.result() + return loop, client + + +def get_factory(scheduler): + """ + Gets protocol factory based on the backend scheduler being used + :param scheduler: REACTOR/IO_LOOP/ASYNC_IO + :return + """ + if scheduler == schedulers.REACTOR: + return reactor_factory + elif scheduler == schedulers.IO_LOOP: + return io_loop_factory + elif scheduler == schedulers.ASYNC_IO: + return async_io_factory + else: + LOGGER.warning("Allowed Schedulers: {}, {}, {}".format( + schedulers.REACTOR, schedulers.IO_LOOP, schedulers.ASYNC_IO + )) + raise Exception("Invalid Scheduler '{}'".format(scheduler)) + diff --git a/modbus/pymodbus/client/asynchronous/mixins.py b/modbus/pymodbus/client/asynchronous/mixins.py new file mode 100644 index 00000000..f5704e4f --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/mixins.py @@ -0,0 +1,75 @@ +import logging +from pymodbus.client.sync import BaseModbusClient +# from pymodbus.bit_read_message import * +# from pymodbus.bit_write_message import * +# from pymodbus.register_read_message import * +# from pymodbus.register_write_message import * +# from pymodbus.diag_message import * +# from pymodbus.file_message import * +# from pymodbus.other_message import * +from pymodbus.constants import Defaults + +from pymodbus.factory import ClientDecoder +from pymodbus.transaction import ModbusSocketFramer + + +LOGGER = logging.getLogger(__name__) + + +class BaseAsyncModbusClient(BaseModbusClient): + """ + This represents the base ModbusAsyncClient. + """ + + def __init__(self, framer=None, timeout=2, **kwargs): + """ Initializes the framer module + + :param framer: The framer to use for the protocol. Default: + ModbusSocketFramer + :type framer: pymodbus.transaction.ModbusSocketFramer + """ + self._connected = False + self._timeout = timeout + + super(BaseAsyncModbusClient, self).__init__( + framer or ModbusSocketFramer(ClientDecoder()), **kwargs + ) + + +class AsyncModbusClientMixin(BaseAsyncModbusClient): + """ + Async Modbus client mixing for UDP and TCP clients + """ + def __init__(self, host="127.0.0.1", port=Defaults.Port, framer=None, + source_address=None, timeout=None, **kwargs): + """ + Initializes a Modbus TCP/UDP asynchronous client + :param host: Host IP address + :param port: Port + :param framer: Framer to use + :param source_address: Specific to underlying client being used + :param timeout: Timeout in seconds + :param kwargs: Extra arguments + """ + super(AsyncModbusClientMixin, self).__init__(framer=framer, **kwargs) + self.host = host + self.port = port + self.source_address = source_address or ("", 0) + self._timeout = timeout if timeout is not None else Defaults.Timeout + + +class AsyncModbusSerialClientMixin(BaseAsyncModbusClient): + """ + Async Modbus Serial Client Mixing + """ + def __init__(self, framer=None, port=None, **kwargs): + """ + Initializes a Async Modbus Serial Client + :param framer: Modbus Framer + :param port: Serial port to use + :param kwargs: Extra arguments if any + """ + super(AsyncModbusSerialClientMixin, self).__init__(framer=framer) + self.port = port + self.serial_settings = kwargs + diff --git a/modbus/pymodbus/client/asynchronous/schedulers/__init__.py b/modbus/pymodbus/client/asynchronous/schedulers/__init__.py new file mode 100644 index 00000000..59173757 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/schedulers/__init__.py @@ -0,0 +1,9 @@ +""" +Backend schedulers to use with generic Async clients +""" +from __future__ import unicode_literals + + +REACTOR = "reactor" +IO_LOOP = "io_loop" +ASYNC_IO = "async_io" diff --git a/modbus/pymodbus/client/asynchronous/serial.py b/modbus/pymodbus/client/asynchronous/serial.py new file mode 100644 index 00000000..5d2e2e81 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/serial.py @@ -0,0 +1,76 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging +from pymodbus.client.asynchronous.factory.serial import get_factory +from pymodbus.transaction import ModbusRtuFramer, ModbusAsciiFramer, ModbusBinaryFramer, ModbusSocketFramer +from pymodbus.factory import ClientDecoder +from pymodbus.exceptions import ParameterException +from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION +from pymodbus.client.asynchronous.schedulers import ASYNC_IO + +logger = logging.getLogger(__name__) + + +class AsyncModbusSerialClient(object): + """ + Actual Async Serial Client to be used. + + To use do:: + + from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient + """ + @classmethod + def _framer(cls, method): + """ + Returns the requested framer + + :method: The serial framer to instantiate + :returns: The requested serial framer + """ + method = method.lower() + if method == 'ascii': + return ModbusAsciiFramer(ClientDecoder()) + elif method == 'rtu': + return ModbusRtuFramer(ClientDecoder()) + elif method == 'binary': + return ModbusBinaryFramer(ClientDecoder()) + elif method == 'socket': + return ModbusSocketFramer(ClientDecoder()) + + raise ParameterException("Invalid framer method requested") + + def __new__(cls, scheduler, method, port, **kwargs): + """ + Scheduler to use: + - reactor (Twisted) + - io_loop (Tornado) + - async_io (asyncio) + The methods to connect are:: + + - ascii + - rtu + - binary + : param scheduler: Backend to use + :param method: The method to use for connection + :param port: The serial port to attach to + :param stopbits: The number of stop bits to use + :param bytesize: The bytesize of the serial messages + :param parity: Which kind of parity to use + :param baudrate: The baud rate to use for the serial device + :param timeout: The timeout between serial requests (default 3s) + :param scheduler: + :param method: + :param port: + :param kwargs: + :return: + """ + if (not (IS_PYTHON3 and PYTHON_VERSION >= (3, 4)) + and scheduler == ASYNC_IO): + logger.critical("ASYNCIO is supported only on python3") + import sys + sys.exit(1) + factory_class = get_factory(scheduler) + framer = cls._framer(method) + yieldable = factory_class(framer=framer, port=port, **kwargs) + return yieldable diff --git a/modbus/pymodbus/client/asynchronous/tcp.py b/modbus/pymodbus/client/asynchronous/tcp.py new file mode 100644 index 00000000..45b7cfdf --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/tcp.py @@ -0,0 +1,47 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging +from pymodbus.client.asynchronous.factory.tcp import get_factory +from pymodbus.constants import Defaults +from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION +from pymodbus.client.asynchronous.schedulers import ASYNC_IO + +logger = logging.getLogger(__name__) + + +class AsyncModbusTCPClient(object): + """ + Actual Async Serial Client to be used. + + To use do:: + + from pymodbus.client.asynchronous.tcp import AsyncModbusTCPClient + """ + def __new__(cls, scheduler, host="127.0.0.1", port=Defaults.Port, + framer=None, source_address=None, timeout=None, **kwargs): + """ + Scheduler to use: + - reactor (Twisted) + - io_loop (Tornado) + - async_io (asyncio) + :param scheduler: Backend to use + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer to use + :param source_address: source address specific to underlying backend + :param timeout: Time out in seconds + :param kwargs: Other extra args specific to Backend being used + :return: + """ + if (not (IS_PYTHON3 and PYTHON_VERSION >= (3, 4)) + and scheduler == ASYNC_IO): + logger.critical("ASYNCIO is supported only on python3") + import sys + sys.exit(1) + factory_class = get_factory(scheduler) + yieldable = factory_class(host=host, port=port, framer=framer, + source_address=source_address, + timeout=timeout, **kwargs) + return yieldable + diff --git a/modbus/pymodbus/client/asynchronous/thread.py b/modbus/pymodbus/client/asynchronous/thread.py new file mode 100644 index 00000000..3b1aee58 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/thread.py @@ -0,0 +1,54 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +from threading import Thread + +import logging + +LOGGER = logging.getLogger(__name__) + + +class EventLoopThread(object): + """ + Event loop controlling the backend event loops (io_loop for tornado, + reactor for twisted and event_loop for Asyncio) + """ + def __init__(self, name, start, stop, *args, **kwargs): + """ + Initialize Event loop thread + :param name: Name of the event loop + :param start: Start method to start the backend event loop + :param stop: Stop method to stop the backend event loop + :param args: + :param kwargs: + """ + self._name = name + self._start_loop = start + self._stop_loop = stop + self._args = args + self._kwargs = kwargs + self._event_loop = Thread(name=self._name, target=self._start) + self._event_loop.daemon = True + + def _start(self): + """ + Starts the backend event loop + :return: + """ + self._start_loop(*self._args, **self._kwargs) + + def start(self): + """ + Starts the backend event loop + :return: + """ + LOGGER.info("Starting Event Loop: 'PyModbus_{}'".format(self._name)) + self._event_loop.start() + + def stop(self): + """ + Stops the backend event loop + :return: + """ + LOGGER.info("Stopping Event Loop: 'PyModbus_{}'".format(self._name)) + self._stop_loop() diff --git a/modbus/pymodbus/client/asynchronous/tls.py b/modbus/pymodbus/client/asynchronous/tls.py new file mode 100644 index 00000000..d2412ff5 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/tls.py @@ -0,0 +1,52 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging +from pymodbus.client.asynchronous.factory.tls import get_factory +from pymodbus.constants import Defaults +from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION +from pymodbus.client.asynchronous.schedulers import ASYNC_IO +from pymodbus.factory import ClientDecoder +from pymodbus.transaction import ModbusTlsFramer + +logger = logging.getLogger(__name__) + + +class AsyncModbusTLSClient(object): + """ + Actual Async TLS Client to be used. + + To use do:: + + from pymodbus.client.asynchronous.tls import AsyncModbusTLSClient + """ + def __new__(cls, scheduler, host="127.0.0.1", port=Defaults.TLSPort, + framer=None, sslctx=None, server_hostname=None, + source_address=None, timeout=None, **kwargs): + """ + Scheduler to use: + - async_io (asyncio) + :param scheduler: Backend to use + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer to use + :param sslctx: The SSLContext to use for TLS (default None and auto create) + :param server_hostname: Target server's name matched for certificate + :param source_address: source address specific to underlying backend + :param timeout: Time out in seconds + :param kwargs: Other extra args specific to Backend being used + :return: + """ + if (not (IS_PYTHON3 and PYTHON_VERSION >= (3, 4)) + and scheduler == ASYNC_IO): + logger.critical("ASYNCIO is supported only on python3") + import sys + sys.exit(1) + framer = framer or ModbusTlsFramer(ClientDecoder()) + factory_class = get_factory(scheduler) + yieldable = factory_class(host=host, port=port, sslctx=sslctx, + server_hostname=server_hostname, + framer=framer, source_address=source_address, + timeout=timeout, **kwargs) + return yieldable + diff --git a/modbus/pymodbus/client/asynchronous/tornado/__init__.py b/modbus/pymodbus/client/asynchronous/tornado/__init__.py new file mode 100644 index 00000000..8eb61f4e --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/tornado/__init__.py @@ -0,0 +1,498 @@ +""" +Asynchronous framework adapter for tornado. +""" +from __future__ import unicode_literals + +import abc + +import logging + +import time +import socket +from serial import Serial +from tornado import gen +from tornado.concurrent import Future +from tornado.ioloop import IOLoop +from tornado.iostream import IOStream +from tornado.iostream import BaseIOStream + +from pymodbus.client.asynchronous.mixins import (AsyncModbusClientMixin, + AsyncModbusSerialClientMixin) + +from pymodbus.compat import byte2int +from pymodbus.exceptions import (ConnectionException, + ModbusIOException, + TimeOutException) +from pymodbus.utilities import (hexlify_packets, + ModbusTransactionState) +from pymodbus.constants import Defaults + + +LOGGER = logging.getLogger(__name__) + + +class BaseTornadoClient(AsyncModbusClientMixin): + """ + Base Tornado client + """ + stream = None + io_loop = None + + def __init__(self, *args, **kwargs): + """ + Initializes BaseTornadoClient. + ioloop to be passed as part of kwargs ('ioloop') + :param args: + :param kwargs: + """ + self.io_loop = kwargs.pop("ioloop", None) + super(BaseTornadoClient, self).__init__(*args, **kwargs) + + @abc.abstractmethod + def get_socket(self): + """ + return instance of the socket to connect to + """ + + @gen.coroutine + def connect(self): + """ + Connect to the socket identified by host and port + + :returns: Future + :rtype: tornado.concurrent.Future + """ + conn = self.get_socket() + self.stream = IOStream(conn, io_loop=self.io_loop or IOLoop.current()) + self.stream.connect((self.host, self.port)) + self.stream.read_until_close(None, + streaming_callback=self.on_receive) + self._connected = True + LOGGER.debug("Client connected") + + raise gen.Return(self) + + def on_receive(self, *args): + """ + On data recieve call back + :param args: data received + :return: + """ + data = args[0] if len(args) > 0 else None + + if not data: + return + LOGGER.debug("recv: " + hexlify_packets(data)) + unit = self.framer.decode_data(data).get("unit", 0) + self.framer.processIncomingPacket(data, self._handle_response, unit=unit) + + def execute(self, request=None): + """ + Executes a transaction + :param request: + :return: + """ + request.transaction_id = self.transaction.getNextTID() + packet = self.framer.buildPacket(request) + LOGGER.debug("send: " + hexlify_packets(packet)) + self.stream.write(packet) + return self._build_response(request.transaction_id) + + def _handle_response(self, reply, **kwargs): + """ + Handle response received + :param reply: + :param kwargs: + :return: + """ + if reply is not None: + tid = reply.transaction_id + future = self.transaction.getTransaction(tid) + if future: + future.set_result(reply) + else: + LOGGER.debug("Unrequested message: {}".format(reply)) + + def _build_response(self, tid): + """ + Builds a future response + :param tid: + :return: + """ + f = Future() + + if not self._connected: + f.set_exception(ConnectionException("Client is not connected")) + return f + + self.transaction.addTransaction(f, tid) + return f + + def close(self): + """ + Closes the underlying IOStream + """ + LOGGER.debug("Client disconnected") + if self.stream: + self.stream.close_fd() + + self.stream = None + self._connected = False + + +class BaseTornadoSerialClient(AsyncModbusSerialClientMixin): + """ + Base Tonado serial client + """ + stream = None + io_loop = None + + def __init__(self, *args, **kwargs): + """ + Initializes BaseTornadoSerialClient. + ioloop to be passed as part of kwargs ('ioloop') + :param args: + :param kwargs: + """ + self.io_loop = kwargs.pop("ioloop", None) + super(BaseTornadoSerialClient, self).__init__(*args, **kwargs) + + @abc.abstractmethod + def get_socket(self): + """ + return instance of the socket to connect to + """ + + def on_receive(self, *args): + # Will be handled ine execute method + pass + + def execute(self, request=None): + """ + Executes a transaction + :param request: Request to be written on to the bus + :return: + """ + request.transaction_id = self.transaction.getNextTID() + + def callback(*args): + LOGGER.debug("in callback - {}".format(request.transaction_id)) + while True: + waiting = self.stream.connection.in_waiting + if waiting: + data = self.stream.connection.read(waiting) + LOGGER.debug( + "recv: " + hexlify_packets(data)) + unit = self.framer.decode_data(data).get("uid", 0) + self.framer.processIncomingPacket( + data, + self._handle_response, + unit, + tid=request.transaction_id + ) + break + + packet = self.framer.buildPacket(request) + LOGGER.debug("send: " + hexlify_packets(packet)) + self.stream.write(packet, callback=callback) + f = self._build_response(request.transaction_id) + return f + + def _handle_response(self, reply, **kwargs): + """ + Handles a received response and updates a future + :param reply: Reply received + :param kwargs: + :return: + """ + if reply is not None: + tid = reply.transaction_id + future = self.transaction.getTransaction(tid) + if future: + future.set_result(reply) + else: + LOGGER.debug("Unrequested message: {}".format(reply)) + + def _build_response(self, tid): + """ + Prepare for a response, returns a future + :param tid: + :return: Future + """ + f = Future() + + if not self._connected: + f.set_exception(ConnectionException("Client is not connected")) + return f + + self.transaction.addTransaction(f, tid) + return f + + def close(self): + """ + Closes the underlying IOStream + """ + LOGGER.debug("Client disconnected") + if self.stream: + self.stream.close_fd() + + self.stream = None + self._connected = False + + +class SerialIOStream(BaseIOStream): + """ + Serial IO Stream class to control and handle serial connections + over tornado + """ + def __init__(self, connection, *args, **kwargs): + """ + Initializes Serial IO Stream + :param connection: serial object + :param args: + :param kwargs: + """ + self.connection = connection + super(SerialIOStream, self).__init__(*args, **kwargs) + + def fileno(self): + """ + Returns serial fd + :return: + """ + return self.connection.fileno() + + def close_fd(self): + """ + Closes a serial Fd + :return: + """ + if self.connection: + self.connection.close() + self.connection = None + + def read_from_fd(self): + """ + Reads from a fd + :return: + """ + try: + chunk = self.connection.readline() + except Exception: + return None + + return chunk + + def write_to_fd(self, data): + """ + Writes to a fd + :param data: + :return: + """ + try: + return self.connection.write(data) + except Exception as e: + LOGGER.error(e) + + +class AsyncModbusSerialClient(BaseTornadoSerialClient): + """ + Tornado based asynchronous serial client + """ + def __init__(self, *args, **kwargs): + """ + Initializes AsyncModbusSerialClient. + :param args: + :param kwargs: + """ + self.state = ModbusTransactionState.IDLE + self.timeout = kwargs.get('timeout', Defaults.Timeout) + self.baudrate = kwargs.get('baudrate', Defaults.Baudrate) + if self.baudrate > 19200: + self.silent_interval = 1.75 / 1000 # ms + else: + self._t0 = float((1 + 8 + 2)) / self.baudrate + self.silent_interval = 3.5 * self._t0 + self.silent_interval = round(self.silent_interval, 6) + self.last_frame_end = 0.0 + super(AsyncModbusSerialClient, self).__init__(*args, **kwargs) + + def get_socket(self): + """ + Creates Pyserial object + :return: serial object + """ + return Serial(port=self.port, **self.serial_settings) + + @gen.coroutine + def connect(self): + """Connect to the socket identified by host and port + + :returns: Future + :rtype: tornado.concurrent.Future + """ + conn = self.get_socket() + if self.io_loop is None: + self.io_loop = IOLoop.current() + try: + self.stream = SerialIOStream(conn, io_loop=self.io_loop) + except Exception as e: + LOGGER.exception(e) + + self._connected = True + LOGGER.debug("Client connected") + + raise gen.Return(self) + + def execute(self, request): + """ + Executes a transaction + :param request: Request to be written on to the bus + :return: + """ + request.transaction_id = self.transaction.getNextTID() + + def _clear_timer(): + """ + Clear serial waiting timeout + """ + if self.timeout_handle: + self.io_loop.remove_timeout(self.timeout_handle) + self.timeout_handle = None + + def _on_timeout(): + """ + Got timeout while waiting data from serial port + """ + LOGGER.warning("serial receive timeout") + _clear_timer() + if self.stream: + self.io_loop.remove_handler(self.stream.fileno()) + self.framer.resetFrame() + transaction = self.transaction.getTransaction(request.transaction_id) + if transaction: + transaction.set_exception(TimeOutException()) + + def _on_write_done(*args): + """ + Set up reader part after sucessful write to the serial + """ + LOGGER.debug("frame sent, waiting for a reply") + self.last_frame_end = round(time.time(), 6) + self.state = ModbusTransactionState.WAITING_FOR_REPLY + self.io_loop.add_handler(self.stream.fileno(), _on_receive, IOLoop.READ) + + def _on_fd_error(fd, *args): + _clear_timer() + self.io_loop.remove_handler(fd) + self.close() + self.transaction.getTransaction(request.transaction_id).set_exception(ModbusIOException(*args)) + + def _on_receive(fd, events): + """ + New data in serial buffer to read or serial port closed + """ + if events & IOLoop.ERROR: + _on_fd_error(fd) + return + + try: + waiting = self.stream.connection.in_waiting + if waiting: + data = self.stream.connection.read(waiting) + LOGGER.debug( + "recv: " + hexlify_packets(data)) + self.last_frame_end = round(time.time(), 6) + except OSError as ex: + _on_fd_error(fd, ex) + return + + self.framer.addToFrame(data) + + # check if we have regular frame or modbus exception + fcode = self.framer.decode_data(self.framer.getRawFrame()).get("fcode", 0) + if fcode and ( + (fcode > 0x80 and len(self.framer.getRawFrame()) == exception_response_length) + or + (len(self.framer.getRawFrame()) == expected_response_length) + ): + _clear_timer() + self.io_loop.remove_handler(fd) + self.state = ModbusTransactionState.IDLE + self.framer.processIncomingPacket( + b'', # already sent via addToFrame() + self._handle_response, + 0, # don't care when `single=True` + single=True, + tid=request.transaction_id + ) + + packet = self.framer.buildPacket(request) + f = self._build_response(request.transaction_id) + + response_pdu_size = request.get_response_pdu_size() + expected_response_length = self.transaction._calculate_response_length(response_pdu_size) + LOGGER.debug("expected_response_length = %d", expected_response_length) + + exception_response_length = self.transaction._calculate_exception_length() # TODO: calculate once + + if self.timeout: + self.timeout_handle = self.io_loop.add_timeout(time.time() + self.timeout, _on_timeout) + self._sendPacket(packet, callback=_on_write_done) + + return f + + def _sendPacket(self, message, callback): + """ + Sends packets on the bus with 3.5char delay between frames + :param message: Message to be sent over the bus + :return: + """ + @gen.coroutine + def sleep(timeout): + yield gen.sleep(timeout) + + try: + waiting = self.stream.connection.in_waiting + if waiting: + result = self.stream.connection.read(waiting) + LOGGER.info( + "Cleanup recv buffer before send: " + hexlify_packets(result)) + except OSError as e: + self.transaction.getTransaction( + message.transaction_id).set_exception(ModbusIOException(e)) + return + + start = time.time() + if self.last_frame_end: + waittime = self.last_frame_end + self.silent_interval - start + if waittime > 0: + LOGGER.debug("Waiting for 3.5 char before next send - %f ms", waittime) + sleep(waittime) + + self.state = ModbusTransactionState.SENDING + LOGGER.debug("send: " + hexlify_packets(message)) + self.stream.write(message, callback) + +class AsyncModbusTCPClient(BaseTornadoClient): + """ + Tornado based Async tcp client + """ + def get_socket(self): + """ + Creates socket object + :return: socket + """ + return socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) + + +class AsyncModbusUDPClient(BaseTornadoClient): + """ + Tornado based Async UDP client + """ + def get_socket(self): + """ + Create socket object + :return: socket + """ + return socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) \ No newline at end of file diff --git a/modbus/pymodbus/client/asynchronous/twisted/__init__.py b/modbus/pymodbus/client/asynchronous/twisted/__init__.py new file mode 100644 index 00000000..f86cae3c --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/twisted/__init__.py @@ -0,0 +1,251 @@ +""" +Implementation of a Modbus Client Using Twisted +-------------------------------------------------- + +Example run:: + + from twisted.internet import reactor, protocol + from pymodbus.client.asynchronous import ModbusClientProtocol + + def printResult(result): + print "Result: %d" % result.bits[0] + + def process(client): + result = client.write_coil(1, True) + result.addCallback(printResult) + reactor.callLater(1, reactor.stop) + + defer = protocol.ClientCreator(reactor, ModbusClientProtocol + ).connectTCP("localhost", 502) + defer.addCallback(process) + +Another example:: + + from twisted.internet import reactor + from pymodbus.client.asynchronous import ModbusClientFactory + + def process(): + factory = reactor.connectTCP("localhost", 502, ModbusClientFactory()) + reactor.stop() + + if __name__ == "__main__": + reactor.callLater(1, process) + reactor.run() +""" +from __future__ import unicode_literals +from twisted.internet import defer, protocol + +from pymodbus.exceptions import ConnectionException +from pymodbus.factory import ClientDecoder +from pymodbus.client.asynchronous.mixins import AsyncModbusClientMixin +from pymodbus.transaction import FifoTransactionManager, DictTransactionManager +from pymodbus.transaction import ModbusSocketFramer, ModbusRtuFramer +from pymodbus.utilities import hexlify_packets +from twisted.python.failure import Failure + + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Connected Client Protocols +# --------------------------------------------------------------------------- # +class ModbusClientProtocol(protocol.Protocol, + AsyncModbusClientMixin): + """ + This represents the base modbus client protocol. All the application + layer code is deferred to a higher level wrapper. + """ + framer = None + + def __init__(self, framer=None, **kwargs): + self._connected = False + self.framer = framer or ModbusSocketFramer(ClientDecoder()) + if isinstance(self.framer, type): + # Framer class not instance + self.framer = self.framer(ClientDecoder(), client=None) + if isinstance(self.framer, ModbusSocketFramer): + self.transaction = DictTransactionManager(self, **kwargs) + else: + self.transaction = FifoTransactionManager(self, **kwargs) + + def connectionMade(self): + """ + Called upon a successful client connection. + """ + _logger.debug("Client connected to modbus server") + self._connected = True + + def connectionLost(self, reason=None): + """ + Called upon a client disconnect + + :param reason: The reason for the disconnect + """ + _logger.debug("Client disconnected from modbus server: %s" % reason) + self._connected = False + for tid in list(self.transaction): + self.transaction.getTransaction(tid).errback(Failure( + ConnectionException('Connection lost during request'))) + + def dataReceived(self, data): + """ + Get response, check for valid message, decode result + + :param data: The data returned from the server + """ + unit = self.framer.decode_data(data).get("unit", 0) + self.framer.processIncomingPacket(data, self._handleResponse, + unit=unit) + + def execute(self, request): + """ + Starts the producer to send the next request to + consumer.write(Frame(request)) + """ + request.transaction_id = self.transaction.getNextTID() + packet = self.framer.buildPacket(request) + _logger.debug("send: " + hexlify_packets(packet)) + self.transport.write(packet) + return self._buildResponse(request.transaction_id) + + def _handleResponse(self, reply, **kwargs): + """ + Handle the processed response and link to correct deferred + + :param reply: The reply to process + """ + if reply is not None: + tid = reply.transaction_id + handler = self.transaction.getTransaction(tid) + if handler: + handler.callback(reply) + else: + _logger.debug("Unrequested message: " + str(reply)) + + def _buildResponse(self, tid): + """ + Helper method to return a deferred response + for the current request. + + :param tid: The transaction identifier for this response + :returns: A defer linked to the latest request + """ + if not self._connected: + return defer.fail(Failure( + ConnectionException('Client is not connected'))) + + d = defer.Deferred() + self.transaction.addTransaction(d, tid) + return d + + def close(self): + """ + Closes underlying transport layer ,essentially closing the client + :return: + """ + if self.transport and hasattr(self.transport, "close"): + self.transport.close() + self._connected = False + + +class ModbusTcpClientProtocol(ModbusClientProtocol): + """ + Async TCP Client protocol based on twisted. + + Default framer: ModbusSocketFramer + """ + framer = ModbusSocketFramer(ClientDecoder()) + + +class ModbusSerClientProtocol(ModbusClientProtocol): + """ + Async Serial Client protocol based on twisted + + Default framer: ModbusRtuFramer + """ + def __init__(self, framer=None, **kwargs): + framer = framer or ModbusRtuFramer(ClientDecoder()) + super(ModbusSerClientProtocol, self).__init__(framer, **kwargs) + + +# --------------------------------------------------------------------------- # +# Not Connected Client Protocol +# --------------------------------------------------------------------------- # +class ModbusUdpClientProtocol(protocol.DatagramProtocol, + AsyncModbusClientMixin): + """ + This represents the base modbus client protocol. All the application + layer code is deferred to a higher level wrapper. + """ + + def datagramReceived(self, data, params): + """ + Get response, check for valid message, decode result + + :param data: The data returned from the server + :param params: The host parameters sending the datagram + """ + _logger.debug("Datagram from: %s:%d" % params) + unit = self.framer.decode_data(data).get("uid", 0) + self.framer.processIncomingPacket(data, self._handleResponse, unit=unit) + + def execute(self, request): + """ + Starts the producer to send the next request to + consumer.write(Frame(request)) + """ + request.transaction_id = self.transaction.getNextTID() + packet = self.framer.buildPacket(request) + self.transport.write(packet, (self.host, self.port)) + return self._buildResponse(request.transaction_id) + + def _handleResponse(self, reply, **kwargs): + """ + Handle the processed response and link to correct deferred + + :param reply: The reply to process + """ + if reply is not None: + tid = reply.transaction_id + handler = self.transaction.getTransaction(tid) + if handler: + handler.callback(reply) + else: _logger.debug("Unrequested message: " + str(reply)) + + def _buildResponse(self, tid): + """ + Helper method to return a deferred response + for the current request. + + :param tid: The transaction identifier for this response + :returns: A defer linked to the latest request + """ + d = defer.Deferred() + self.transaction.addTransaction(d, tid) + return d + + +# --------------------------------------------------------------------------- # +# Client Factories +# --------------------------------------------------------------------------- # +class ModbusClientFactory(protocol.ReconnectingClientFactory): + """ Simple client protocol factory """ + + protocol = ModbusClientProtocol + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + + +__all__ = [ + "ModbusClientProtocol", + "ModbusUdpClientProtocol", + "ModbusClientFactory" +] + diff --git a/modbus/pymodbus/client/asynchronous/udp.py b/modbus/pymodbus/client/asynchronous/udp.py new file mode 100644 index 00000000..a111d962 --- /dev/null +++ b/modbus/pymodbus/client/asynchronous/udp.py @@ -0,0 +1,47 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging +from pymodbus.constants import Defaults +from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION +from pymodbus.client.asynchronous.schedulers import ASYNC_IO +from pymodbus.client.asynchronous.factory.udp import get_factory + +logger = logging.getLogger(__name__) + + +class AsyncModbusUDPClient(object): + """ + Actual Async UDP Client to be used. + + To use do:: + + from pymodbus.client.asynchronous.tcp import AsyncModbusUDPClient + """ + def __new__(cls, scheduler, host="127.0.0.1", port=Defaults.Port, + framer=None, source_address=None, timeout=None, **kwargs): + """ + Scheduler to use: + - reactor (Twisted) + - io_loop (Tornado) + - async_io (asyncio) + :param scheduler: Backend to use + :param host: Host IP address + :param port: Port + :param framer: Modbus Framer to use + :param source_address: source address specific to underlying backend + :param timeout: Time out in seconds + :param kwargs: Other extra args specific to Backend being used + :return: + """ + if (not (IS_PYTHON3 and PYTHON_VERSION >= (3, 4)) + and scheduler == ASYNC_IO): + logger.critical("ASYNCIO is supported only on python3") + import sys + sys.exit(1) + factory_class = get_factory(scheduler) + yieldable = factory_class(host=host, port=port, framer=framer, + source_address=source_address, + timeout=timeout, **kwargs) + return yieldable + diff --git a/modbus/pymodbus/client/common.py b/modbus/pymodbus/client/common.py new file mode 100644 index 00000000..ea3c981c --- /dev/null +++ b/modbus/pymodbus/client/common.py @@ -0,0 +1,155 @@ +''' +Modbus Client Common +---------------------------------- + +This is a common client mixin that can be used by +both the synchronous and asynchronous clients to +simplify the interface. +''' +from pymodbus.bit_read_message import * +from pymodbus.bit_write_message import * +from pymodbus.register_read_message import * +from pymodbus.register_write_message import * +from pymodbus.diag_message import * +from pymodbus.file_message import * +from pymodbus.other_message import * + +from pymodbus.utilities import ModbusTransactionState + + +class ModbusClientMixin(object): + ''' + This is a modbus client mixin that provides additional factory + methods for all the current modbus methods. This can be used + instead of the normal pattern of:: + + # instead of this + client = ModbusClient(...) + request = ReadCoilsRequest(1,10) + response = client.execute(request) + + # now like this + client = ModbusClient(...) + response = client.read_coils(1, 10) + ''' + state = ModbusTransactionState.IDLE + last_frame_end = 0 + silent_interval = 0 + + def read_coils(self, address, count=1, **kwargs): + ''' + + :param address: The starting address to read from + :param count: The number of coils to read + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = ReadCoilsRequest(address, count, **kwargs) + return self.execute(request) + + def read_discrete_inputs(self, address, count=1, **kwargs): + ''' + + :param address: The starting address to read from + :param count: The number of discretes to read + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = ReadDiscreteInputsRequest(address, count, **kwargs) + return self.execute(request) + + def write_coil(self, address, value, **kwargs): + ''' + + :param address: The starting address to write to + :param value: The value to write to the specified address + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = WriteSingleCoilRequest(address, value, **kwargs) + return self.execute(request) + + def write_coils(self, address, values, **kwargs): + ''' + + :param address: The starting address to write to + :param values: The values to write to the specified address + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = WriteMultipleCoilsRequest(address, values, **kwargs) + return self.execute(request) + + def write_register(self, address, value, **kwargs): + ''' + + :param address: The starting address to write to + :param value: The value to write to the specified address + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = WriteSingleRegisterRequest(address, value, **kwargs) + return self.execute(request) + + def write_registers(self, address, values, **kwargs): + ''' + + :param address: The starting address to write to + :param values: The values to write to the specified address + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = WriteMultipleRegistersRequest(address, values, **kwargs) + return self.execute(request) + + def read_holding_registers(self, address, count=1, **kwargs): + ''' + + :param address: The starting address to read from + :param count: The number of registers to read + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = ReadHoldingRegistersRequest(address, count, **kwargs) + return self.execute(request) + + def read_input_registers(self, address, count=1, **kwargs): + ''' + + :param address: The starting address to read from + :param count: The number of registers to read + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = ReadInputRegistersRequest(address, count, **kwargs) + return self.execute(request) + + def readwrite_registers(self, *args, **kwargs): + ''' + + :param read_address: The address to start reading from + :param read_count: The number of registers to read from address + :param write_address: The address to start writing to + :param write_registers: The registers to write to the specified address + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = ReadWriteMultipleRegistersRequest(*args, **kwargs) + return self.execute(request) + + def mask_write_register(self, *args, **kwargs): + ''' + + :param address: The address of the register to write + :param and_mask: The and bitmask to apply to the register address + :param or_mask: The or bitmask to apply to the register address + :param unit: The slave unit this request is targeting + :returns: A deferred response handle + ''' + request = MaskWriteRegisterRequest(*args, **kwargs) + return self.execute(request) + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ 'ModbusClientMixin' ] diff --git a/modbus/pymodbus/client/sync.py b/modbus/pymodbus/client/sync.py new file mode 100644 index 00000000..571ba0f4 --- /dev/null +++ b/modbus/pymodbus/client/sync.py @@ -0,0 +1,779 @@ +import socket +import select +import serial +import time +import ssl +import sys +from functools import partial +from pymodbus.constants import Defaults +from pymodbus.utilities import hexlify_packets, ModbusTransactionState +from pymodbus.factory import ClientDecoder +from pymodbus.exceptions import NotImplementedException, ParameterException +from pymodbus.exceptions import ConnectionException +from pymodbus.transaction import FifoTransactionManager +from pymodbus.transaction import DictTransactionManager +from pymodbus.transaction import ModbusSocketFramer, ModbusBinaryFramer +from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer +from pymodbus.transaction import ModbusTlsFramer +from pymodbus.client.common import ModbusClientMixin + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- # +# The Synchronous Clients +# --------------------------------------------------------------------------- # + + +class BaseModbusClient(ModbusClientMixin): + """ + Inteface for a modbus synchronous client. Defined here are all the + methods for performing the related request methods. Derived classes + simply need to implement the transport methods and set the correct + framer. + """ + def __init__(self, framer, **kwargs): + """ Initialize a client instance + + :param framer: The modbus framer implementation to use + """ + self.framer = framer + self.transaction = DictTransactionManager(self, **kwargs) + self._debug = False + self._debugfd = None + self.broadcast_enable = kwargs.get('broadcast_enable', Defaults.broadcast_enable) + + # ----------------------------------------------------------------------- # + # Client interface + # ----------------------------------------------------------------------- # + def connect(self): + """ Connect to the modbus remote host + + :returns: True if connection succeeded, False otherwise + """ + raise NotImplementedException("Method not implemented by derived class") + + def close(self): + """ Closes the underlying socket connection + """ + pass + + def is_socket_open(self): + """ + Check whether the underlying socket/serial is open or not. + + :returns: True if socket/serial is open, False otherwise + """ + raise NotImplementedException( + "is_socket_open() not implemented by {}".format(self.__str__()) + ) + + def send(self, request): + if self.state != ModbusTransactionState.RETRYING: + _logger.debug("New Transaction state 'SENDING'") + self.state = ModbusTransactionState.SENDING + return self._send(request) + + def _send(self, request): + """ Sends data on the underlying socket + + :param request: The encoded request to send + :return: The number of bytes written + """ + raise NotImplementedException("Method not implemented by derived class") + + def recv(self, size): + return self._recv(size) + + def _recv(self, size): + """ Reads data from the underlying descriptor + + :param size: The number of bytes to read + :return: The bytes read + """ + raise NotImplementedException("Method not implemented by derived class") + + # ----------------------------------------------------------------------- # + # Modbus client methods + # ----------------------------------------------------------------------- # + def execute(self, request=None): + """ + :param request: The request to process + :returns: The result of the request execution + """ + if not self.connect(): + raise ConnectionException("Failed to connect[%s]" % (self.__str__())) + return self.transaction.execute(request) + + # ----------------------------------------------------------------------- # + # The magic methods + # ----------------------------------------------------------------------- # + def __enter__(self): + """ Implement the client with enter block + + :returns: The current instance of the client + """ + if not self.connect(): + raise ConnectionException("Failed to connect[%s]" % (self.__str__())) + return self + + def __exit__(self, klass, value, traceback): + """ Implement the client with exit block """ + self.close() + + def idle_time(self): + """ + Bus Idle Time to initiate next transaction + :return: time stamp + """ + if self.last_frame_end is None or self.silent_interval is None: + return 0 + return self.last_frame_end + self.silent_interval + + def debug_enabled(self): + """ + Returns a boolean indicating if debug is enabled. + """ + return self._debug + + def set_debug(self, debug): + """ + Sets the current debug flag. + """ + self._debug = debug + + def trace(self, writeable): + if writeable: + self.set_debug(True) + self._debugfd = writeable + + def _dump(self, data, direction): + fd = self._debugfd if self._debugfd else sys.stdout + try: + fd.write(hexlify_packets(data)) + except Exception as e: + _logger.debug(hexlify_packets(data)) + _logger.exception(e) + + def register(self, function): + """ + Registers a function and sub function class with the decoder + :param function: Custom function class to register + :return: + """ + self.framer.decoder.register(function) + + def __str__(self): + """ Builds a string representation of the connection + + :returns: The string representation + """ + return "Null Transport" + + +# --------------------------------------------------------------------------- # +# Modbus TCP Client Transport Implementation +# --------------------------------------------------------------------------- # +class ModbusTcpClient(BaseModbusClient): + """ Implementation of a modbus tcp client + """ + + def __init__(self, host='127.0.0.1', port=Defaults.Port, + framer=ModbusSocketFramer, **kwargs): + """ Initialize a client instance + + :param host: The host to connect to (default 127.0.0.1) + :param port: The modbus port to connect to (default 502) + :param source_address: The source address tuple to bind to (default ('', 0)) + :param timeout: The timeout to use for this socket (default Defaults.Timeout) + :param framer: The modbus framer to use (default ModbusSocketFramer) + + .. note:: The host argument will accept ipv4 and ipv6 hosts + """ + self.host = host + self.port = port + self.source_address = kwargs.get('source_address', ('', 0)) + self.socket = None + self.timeout = kwargs.get('timeout', Defaults.Timeout) + BaseModbusClient.__init__(self, framer(ClientDecoder(), self), **kwargs) + + def connect(self): + """ Connect to the modbus tcp server + + :returns: True if connection succeeded, False otherwise + """ + if self.socket: + return True + try: + self.socket = socket.create_connection( + (self.host, self.port), + timeout=self.timeout, + source_address=self.source_address) + _logger.debug("Connection to Modbus server established. " + "Socket {}".format(self.socket.getsockname())) + except socket.error as msg: + _logger.error('Connection to (%s, %s) ' + 'failed: %s' % (self.host, self.port, msg)) + self.close() + return self.socket is not None + + def close(self): + """ Closes the underlying socket connection + """ + if self.socket: + self.socket.close() + self.socket = None + + def _check_read_buffer(self, recv_size=None): + time_ = time.time() + end = time_ + self.timeout + data = None + ready = select.select([self.socket], [], [], end - time_) + if ready[0]: + data = self.socket.recv(1024) + return data + + def _send(self, request): + """ Sends data on the underlying socket + + :param request: The encoded request to send + :return: The number of bytes written + """ + if not self.socket: + raise ConnectionException(self.__str__()) + if self.state == ModbusTransactionState.RETRYING: + data = self._check_read_buffer() + if data: + return data + + if request: + return self.socket.send(request) + return 0 + + def _recv(self, size): + """ Reads data from the underlying descriptor + + :param size: The number of bytes to read + :return: The bytes read if the peer sent a response, or a zero-length + response if no data packets were received from the client at + all. + :raises: ConnectionException if the socket is not initialized, or the + peer either has closed the connection before this method is + invoked or closes it before sending any data before timeout. + """ + if not self.socket: + raise ConnectionException(self.__str__()) + + # socket.recv(size) waits until it gets some data from the host but + # not necessarily the entire response that can be fragmented in + # many packets. + # To avoid the splitted responses to be recognized as invalid + # messages and to be discarded, loops socket.recv until full data + # is received or timeout is expired. + # If timeout expires returns the read data, also if its length is + # less than the expected size. + self.socket.setblocking(0) + + timeout = self.timeout + + # If size isn't specified read up to 4096 bytes at a time. + if size is None: + recv_size = 4096 + else: + recv_size = size + + data = [] + data_length = 0 + time_ = time.time() + end = time_ + timeout + while recv_size > 0: + ready = select.select([self.socket], [], [], end - time_) + if ready[0]: + recv_data = self.socket.recv(recv_size) + if recv_data == b'': + return self._handle_abrupt_socket_close( + size, data, time.time() - time_) + data.append(recv_data) + data_length += len(recv_data) + time_ = time.time() + + # If size isn't specified continue to read until timeout expires. + if size: + recv_size = size - data_length + + # Timeout is reduced also if some data has been received in order + # to avoid infinite loops when there isn't an expected response + # size and the slave sends noisy data continuously. + if time_ > end: + break + + return b"".join(data) + + def _handle_abrupt_socket_close(self, size, data, duration): + """ Handle unexpected socket close by remote end + + Intended to be invoked after determining that the remote end + has unexpectedly closed the connection, to clean up and handle + the situation appropriately. + + :param size: The number of bytes that was attempted to read + :param data: The actual data returned + :param duration: Duration from the read was first attempted + until it was determined that the remote closed the + socket + :return: The more than zero bytes read from the remote end + :raises: ConnectionException If the remote end didn't send any + data at all before closing the connection. + """ + self.close() + readsize = ("read of %s bytes" % size if size + else "unbounded read") + msg = ("%s: Connection unexpectedly closed " + "%.6f seconds into %s" % (self, duration, readsize)) + if data: + result = b"".join(data) + msg += " after returning %s bytes" % len(result) + _logger.warning(msg) + return result + msg += " without response from unit before it closed connection" + raise ConnectionException(msg) + + def is_socket_open(self): + return True if self.socket is not None else False + + def __str__(self): + """ Builds a string representation of the connection + + :returns: The string representation + """ + return "ModbusTcpClient(%s:%s)" % (self.host, self.port) + + def __repr__(self): + return ( + "<{} at {} socket={self.socket}, ipaddr={self.host}, " + "port={self.port}, timeout={self.timeout}>" + ).format(self.__class__.__name__, hex(id(self)), self=self) + +# --------------------------------------------------------------------------- # +# Modbus TLS Client Transport Implementation +# --------------------------------------------------------------------------- # + + +class ModbusTlsClient(ModbusTcpClient): + """ Implementation of a modbus tls client + """ + + def __init__(self, host='localhost', port=Defaults.TLSPort, sslctx=None, + framer=ModbusTlsFramer, **kwargs): + """ Initialize a client instance + + :param host: The host to connect to (default localhost) + :param port: The modbus port to connect to (default 802) + :param sslctx: The SSLContext to use for TLS (default None and auto create) + :param source_address: The source address tuple to bind to (default ('', 0)) + :param timeout: The timeout to use for this socket (default Defaults.Timeout) + :param framer: The modbus framer to use (default ModbusSocketFramer) + + .. note:: The host argument will accept ipv4 and ipv6 hosts + """ + self.sslctx = sslctx + if self.sslctx is None: + self.sslctx = ssl.create_default_context() + # According to MODBUS/TCP Security Protocol Specification, it is + # TLSv2 at least + self.sslctx.options |= ssl.OP_NO_TLSv1_1 + self.sslctx.options |= ssl.OP_NO_TLSv1 + self.sslctx.options |= ssl.OP_NO_SSLv3 + self.sslctx.options |= ssl.OP_NO_SSLv2 + ModbusTcpClient.__init__(self, host, port, framer, **kwargs) + + def connect(self): + """ Connect to the modbus tls server + + :returns: True if connection succeeded, False otherwise + """ + if self.socket: return True + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(self.source_address) + self.socket = self.sslctx.wrap_socket(sock, server_side=False, + server_hostname=self.host) + self.socket.settimeout(self.timeout) + self.socket.connect((self.host, self.port)) + except socket.error as msg: + _logger.error('Connection to (%s, %s) ' + 'failed: %s' % (self.host, self.port, msg)) + self.close() + return self.socket is not None + + def _recv(self, size): + """ Reads data from the underlying descriptor + + :param size: The number of bytes to read + :return: The bytes read + """ + if not self.socket: + raise ConnectionException(self.__str__()) + + # socket.recv(size) waits until it gets some data from the host but + # not necessarily the entire response that can be fragmented in + # many packets. + # To avoid the splitted responses to be recognized as invalid + # messages and to be discarded, loops socket.recv until full data + # is received or timeout is expired. + # If timeout expires returns the read data, also if its length is + # less than the expected size. + timeout = self.timeout + + # If size isn't specified read 1 byte at a time. + if size is None: + recv_size = 1 + else: + recv_size = size + + data = b'' + time_ = time.time() + end = time_ + timeout + while recv_size > 0: + data += self.socket.recv(recv_size) + time_ = time.time() + + # If size isn't specified continue to read until timeout expires. + if size: + recv_size = size - len(data) + + # Timeout is reduced also if some data has been received in order + # to avoid infinite loops when there isn't an expected response + # size and the slave sends noisy data continuously. + if time_ > end: + break + + return data + + def __str__(self): + """ Builds a string representation of the connection + + :returns: The string representation + """ + return "ModbusTlsClient(%s:%s)" % (self.host, self.port) + + def __repr__(self): + return ( + "<{} at {} socket={self.socket}, ipaddr={self.host}, " + "port={self.port}, sslctx={self.sslctx}, timeout={self.timeout}>" + ).format(self.__class__.__name__, hex(id(self)), self=self) + + +# --------------------------------------------------------------------------- # +# Modbus UDP Client Transport Implementation +# --------------------------------------------------------------------------- # + + +class ModbusUdpClient(BaseModbusClient): + """ Implementation of a modbus udp client + """ + + def __init__(self, host='127.0.0.1', port=Defaults.Port, + framer=ModbusSocketFramer, **kwargs): + """ Initialize a client instance + + :param host: The host to connect to (default 127.0.0.1) + :param port: The modbus port to connect to (default 502) + :param framer: The modbus framer to use (default ModbusSocketFramer) + :param timeout: The timeout to use for this socket (default None) + """ + self.host = host + self.port = port + self.socket = None + self.timeout = kwargs.get('timeout', None) + BaseModbusClient.__init__(self, framer(ClientDecoder(), self), **kwargs) + + @classmethod + def _get_address_family(cls, address): + """ A helper method to get the correct address family + for a given address. + + :param address: The address to get the af for + :returns: AF_INET for ipv4 and AF_INET6 for ipv6 + """ + try: + _ = socket.inet_pton(socket.AF_INET6, address) + except socket.error: # not a valid ipv6 address + return socket.AF_INET + return socket.AF_INET6 + + def connect(self): + """ Connect to the modbus tcp server + + :returns: True if connection succeeded, False otherwise + """ + if self.socket: + return True + try: + family = ModbusUdpClient._get_address_family(self.host) + self.socket = socket.socket(family, socket.SOCK_DGRAM) + self.socket.settimeout(self.timeout) + except socket.error as ex: + _logger.error('Unable to create udp socket %s' % ex) + self.close() + return self.socket is not None + + def close(self): + """ Closes the underlying socket connection + """ + self.socket = None + + def _send(self, request): + """ Sends data on the underlying socket + + :param request: The encoded request to send + :return: The number of bytes written + """ + if not self.socket: + raise ConnectionException(self.__str__()) + if request: + return self.socket.sendto(request, (self.host, self.port)) + return 0 + + def _recv(self, size): + """ Reads data from the underlying descriptor + + :param size: The number of bytes to read + :return: The bytes read + """ + if not self.socket: + raise ConnectionException(self.__str__()) + return self.socket.recvfrom(size)[0] + + def is_socket_open(self): + if self.socket: + return True + return self.connect() + + def __str__(self): + """ Builds a string representation of the connection + + :returns: The string representation + """ + return "ModbusUdpClient(%s:%s)" % (self.host, self.port) + + def __repr__(self): + return ( + "<{} at {} socket={self.socket}, ipaddr={self.host}, " + "port={self.port}, timeout={self.timeout}>" + ).format(self.__class__.__name__, hex(id(self)), self=self) + +# --------------------------------------------------------------------------- # +# Modbus Serial Client Transport Implementation +# --------------------------------------------------------------------------- # + + +class ModbusSerialClient(BaseModbusClient): + """ Implementation of a modbus serial client + """ + state = ModbusTransactionState.IDLE + inter_char_timeout = 0 + silent_interval = 0 + + def __init__(self, method='ascii', **kwargs): + """ Initialize a serial client instance + + The methods to connect are:: + + - ascii + - rtu + - binary + + :param method: The method to use for connection + :param port: The serial port to attach to + :param stopbits: The number of stop bits to use + :param bytesize: The bytesize of the serial messages + :param parity: Which kind of parity to use + :param baudrate: The baud rate to use for the serial device + :param timeout: The timeout between serial requests (default 3s) + :param strict: Use Inter char timeout for baudrates <= 19200 (adhere + to modbus standards) + :param handle_local_echo: Handle local echo of the USB-to-RS485 adaptor + """ + self.method = method + self.socket = None + BaseModbusClient.__init__(self, self.__implementation(method, self), + **kwargs) + + self.port = kwargs.get('port', 0) + self.stopbits = kwargs.get('stopbits', Defaults.Stopbits) + self.bytesize = kwargs.get('bytesize', Defaults.Bytesize) + self.parity = kwargs.get('parity', Defaults.Parity) + self.baudrate = kwargs.get('baudrate', Defaults.Baudrate) + self.timeout = kwargs.get('timeout', Defaults.Timeout) + self._strict = kwargs.get("strict", False) + self.last_frame_end = None + self.handle_local_echo = kwargs.get("handle_local_echo", False) + if self.method == "rtu": + if self.baudrate > 19200: + self.silent_interval = 1.75 / 1000 # ms + else: + self._t0 = float((1 + 8 + 2)) / self.baudrate + self.inter_char_timeout = 1.5 * self._t0 + self.silent_interval = 3.5 * self._t0 + self.silent_interval = round(self.silent_interval, 6) + + @staticmethod + def __implementation(method, client): + """ Returns the requested framer + + :method: The serial framer to instantiate + :returns: The requested serial framer + """ + method = method.lower() + if method == 'ascii': + return ModbusAsciiFramer(ClientDecoder(), client) + elif method == 'rtu': + return ModbusRtuFramer(ClientDecoder(), client) + elif method == 'binary': + return ModbusBinaryFramer(ClientDecoder(), client) + elif method == 'socket': + return ModbusSocketFramer(ClientDecoder(), client) + raise ParameterException("Invalid framer method requested") + + def connect(self): + """ Connect to the modbus serial server + + :returns: True if connection succeeded, False otherwise + """ + import serial + if self.socket: + return True + try: + self.socket = serial.serial_for_url(self.port, + timeout=self.timeout, + bytesize=self.bytesize, + stopbits=self.stopbits, + baudrate=self.baudrate, + parity=self.parity) + if self.method == "rtu": + if self._strict: + self.socket.interCharTimeout = self.inter_char_timeout + self.last_frame_end = None + except serial.SerialException as msg: + _logger.error(msg) + self.close() + return self.socket is not None + + def close(self): + """ Closes the underlying socket connection + """ + if self.socket: + self.socket.close() + self.socket = None + + def _in_waiting(self): + in_waiting = ("in_waiting" if hasattr( + self.socket, "in_waiting") else "inWaiting") + + if in_waiting == "in_waiting": + waitingbytes = getattr(self.socket, in_waiting) + else: + waitingbytes = getattr(self.socket, in_waiting)() + return waitingbytes + + def _send(self, request): + """ Sends data on the underlying socket + + If receive buffer still holds some data then flush it. + + Sleep if last send finished less than 3.5 character + times ago. + + :param request: The encoded request to send + :return: The number of bytes written + """ + if not self.socket: + raise ConnectionException(self.__str__()) + if request: + try: + waitingbytes = self._in_waiting() + if waitingbytes: + result = self.socket.read(waitingbytes) + if self.state == ModbusTransactionState.RETRYING: + _logger.debug("Sending available data in recv " + "buffer {}".format( + hexlify_packets(result))) + return result + if _logger.isEnabledFor(logging.WARNING): + _logger.warning("Cleanup recv buffer before " + "send: " + hexlify_packets(result)) + except NotImplementedError: + pass + if self.state != ModbusTransactionState.SENDING: + _logger.debug("New Transaction state 'SENDING'") + self.state = ModbusTransactionState.SENDING + size = self.socket.write(request) + return size + return 0 + + def _wait_for_data(self): + size = 0 + more_data = False + if self.timeout is not None and self.timeout != 0: + condition = partial(lambda start, timeout: + (time.time() - start) <= timeout, + timeout=self.timeout) + else: + condition = partial(lambda dummy1, dummy2: True, dummy2=None) + start = time.time() + while condition(start): + avaialble = self._in_waiting() + if (more_data and not avaialble) or (more_data and avaialble == size): + break + if avaialble and avaialble != size: + more_data = True + size = avaialble + time.sleep(0.01) + return size + + def _recv(self, size): + """ Reads data from the underlying descriptor + + :param size: The number of bytes to read + :return: The bytes read + """ + if not self.socket: + raise ConnectionException(self.__str__()) + if size is None: + size = self._wait_for_data() + result = self.socket.read(size) + return result + + def is_socket_open(self): + if self.socket: + if hasattr(self.socket, "is_open"): + return self.socket.is_open + else: + return self.socket.isOpen() + return False + + def __str__(self): + """ Builds a string representation of the connection + + :returns: The string representation + """ + return "ModbusSerialClient(%s baud[%s])" % (self.method, self.baudrate) + + def __repr__(self): + return ( + "<{} at {} socket={self.socket}, method={self.method}, " + "timeout={self.timeout}>" + ).format(self.__class__.__name__, hex(id(self)), self=self) + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + + +__all__ = [ + "ModbusTcpClient", "ModbusTlsClient", "ModbusUdpClient", "ModbusSerialClient" +] diff --git a/modbus/pymodbus/client/sync_diag.py b/modbus/pymodbus/client/sync_diag.py new file mode 100644 index 00000000..e2e41291 --- /dev/null +++ b/modbus/pymodbus/client/sync_diag.py @@ -0,0 +1,167 @@ +import socket +import logging +import time + +from pymodbus.constants import Defaults +from pymodbus.client.sync import ModbusTcpClient +from pymodbus.transaction import ModbusSocketFramer +from pymodbus.exceptions import ConnectionException + +_logger = logging.getLogger(__name__) + +LOG_MSGS = { + 'conn_msg': 'Connecting to modbus device %s', + 'connfail_msg': 'Connection to (%s, %s) failed: %s', + 'discon_msg': 'Disconnecting from modbus device %s', + 'timelimit_read_msg': + 'Modbus device read took %.4f seconds, ' + 'returned %s bytes in timelimit read', + 'timeout_msg': + 'Modbus device timeout after %.4f seconds, ' + 'returned %s bytes %s', + 'delay_msg': + 'Modbus device read took %.4f seconds, ' + 'returned %s bytes of %s expected', + 'read_msg': + 'Modbus device read took %.4f seconds, ' + 'returned %s bytes of %s expected', + 'unexpected_dc_msg': '%s %s'} + + +class ModbusTcpDiagClient(ModbusTcpClient): + """ + Variant of pymodbus.client.sync.ModbusTcpClient with additional + logging to diagnose network issues. + + The following events are logged: + + +---------+-----------------------------------------------------------------+ + | Level | Events | + +=========+=================================================================+ + | ERROR | Failure to connect to modbus unit; unexpected disconnect by | + | | modbus unit | + +---------+-----------------------------------------------------------------+ + | WARNING | Timeout on normal read; read took longer than warn_delay_limit | + +---------+-----------------------------------------------------------------+ + | INFO | Connection attempt to modbus unit; disconnection from modbus | + | | unit; each time limited read | + +---------+-----------------------------------------------------------------+ + | DEBUG | Normal read with timing information | + +---------+-----------------------------------------------------------------+ + + Reads are differentiated between "normal", which reads a specified number of + bytes, and "time limited", which reads all data for a duration equal to the + timeout period configured for this instance. + """ + + # pylint: disable=no-member + + def __init__(self, host='127.0.0.1', port=Defaults.Port, + framer=ModbusSocketFramer, **kwargs): + """ Initialize a client instance + + The keys of LOG_MSGS can be used in kwargs to customize the messages. + + :param host: The host to connect to (default 127.0.0.1) + :param port: The modbus port to connect to (default 502) + :param source_address: The source address tuple to bind to (default ('', 0)) + :param timeout: The timeout to use for this socket (default Defaults.Timeout) + :param warn_delay_limit: Log reads that take longer than this as warning. + Default True sets it to half of "timeout". None never logs these as + warning, 0 logs everything as warning. + :param framer: The modbus framer to use (default ModbusSocketFramer) + + .. note:: The host argument will accept ipv4 and ipv6 hosts + """ + self.warn_delay_limit = kwargs.get('warn_delay_limit', True) + super(ModbusTcpDiagClient, self).__init__(host, port, framer, **kwargs) + if self.warn_delay_limit is True: + self.warn_delay_limit = self.timeout / 2 + + # Set logging messages, defaulting to LOG_MSGS + for (k, v) in LOG_MSGS.items(): + self.__dict__[k] = kwargs.get(k, v) + + def connect(self): + """ Connect to the modbus tcp server + + :returns: True if connection succeeded, False otherwise + """ + if self.socket: + return True + try: + _logger.info(self.conn_msg, self) + self.socket = socket.create_connection( + (self.host, self.port), + timeout=self.timeout, + source_address=self.source_address) + except socket.error as msg: + _logger.error(self.connfail_msg, self.host, self.port, msg) + self.close() + return self.socket is not None + + def close(self): + """ Closes the underlying socket connection + """ + if self.socket: + _logger.info(self.discon_msg, self) + self.socket.close() + self.socket = None + + def _recv(self, size): + try: + start = time.time() + + result = super(ModbusTcpDiagClient, self)._recv(size) + + delay = time.time() - start + if self.warn_delay_limit is not None and delay >= self.warn_delay_limit: + self._log_delayed_response(len(result), size, delay) + elif not size: + _logger.debug(self.timelimit_read_msg, delay, len(result)) + else: + _logger.debug(self.read_msg, delay, len(result), size) + + return result + except ConnectionException as ex: + # Only log actual network errors, "if not self.socket" then it's a internal code issue + if 'Connection unexpectedly closed' in ex.string: + _logger.error(self.unexpected_dc_msg, self, ex) + raise ex + + def _log_delayed_response(self, result_len, size, delay): + if not size and result_len > 0: + _logger.info(self.timelimit_read_msg, delay, result_len) + elif (result_len == 0 or (size and result_len < size)) and delay >= self.timeout: + read_type = ("of %i expected" % size) if size else "in timelimit read" + _logger.warning(self.timeout_msg, delay, result_len, read_type) + else: + _logger.warning(self.delay_msg, delay, result_len, size) + + def __str__(self): + """ Builds a string representation of the connection + + :returns: The string representation + """ + return "ModbusTcpDiagClient(%s:%s)" % (self.host, self.port) + + +def get_client(): + """ Returns an appropriate client based on logging level + + This will be ModbusTcpDiagClient by default, or the parent class + if the log level is such that the diagnostic client will not log + anything. + + :returns: ModbusTcpClient or a child class thereof + """ + return ModbusTcpDiagClient if _logger.isEnabledFor(logging.ERROR) else ModbusTcpClient + + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + +__all__ = [ + "ModbusTcpDiagClient", "get_client" +] diff --git a/modbus/pymodbus/compat.py b/modbus/pymodbus/compat.py new file mode 100644 index 00000000..33e37dbb --- /dev/null +++ b/modbus/pymodbus/compat.py @@ -0,0 +1,98 @@ +""" +Python 2.x/3.x Compatibility Layer +------------------------------------------------- + +This is mostly based on the jinja2 compat code: + + Some py2/py3 compatibility support based on a stripped down + version of six so we don't have to depend on a specific version + of it. + + :copyright: Copyright 2013 by the Jinja team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" +import sys +import six + +# --------------------------------------------------------------------------- # +# python version checks +# --------------------------------------------------------------------------- # +PYTHON_VERSION = sys.version_info +IS_PYTHON2 = six.PY2 +IS_PYTHON3 = six.PY3 +IS_PYPY = hasattr(sys, 'pypy_translation_info') +IS_JYTHON = sys.platform.startswith('java') + +# --------------------------------------------------------------------------- # +# python > 3.3 compatibility layer +# --------------------------------------------------------------------------- # +# ----------------------------------------------------------------------- # +# portable builtins +# ----------------------------------------------------------------------- # +int2byte = six.int2byte +unichr = six.unichr +range_type = six.moves.range +text_type = six.string_types +string_types = six.string_types +iterkeys = six.iterkeys +itervalues = six.itervalues +iteritems = six.iteritems +get_next = six.next +unicode_string = six.u + +NativeStringIO = six.StringIO +ifilter = six.moves.filter +imap = six.moves.map +izip = six.moves.zip +intern = six.moves.intern + +if not IS_PYTHON2: + # ----------------------------------------------------------------------- # + # module renames + # ----------------------------------------------------------------------- # + import socketserver + # #609 monkey patch for socket server memory leaks + # Refer https://bugs.python.org/issue37193 + socketserver.ThreadingMixIn.daemon_threads = True + # ----------------------------------------------------------------------- # + # decorators + # ----------------------------------------------------------------------- # + implements_to_string = lambda x: x + + byte2int = lambda b: b + if PYTHON_VERSION >= (3, 4): + def is_installed(module): + import importlib.util + found = importlib.util.find_spec(module) + return found + else: + def is_installed(module): + import importlib + found = importlib.find_loader(module) + return found +# --------------------------------------------------------------------------- # +# python > 2.5 compatability layer +# --------------------------------------------------------------------------- # +else: + byte2int = six.byte2int + # ----------------------------------------------------------------------- # + # module renames + + # ----------------------------------------------------------------------- # + import SocketServer as socketserver + + # ----------------------------------------------------------------------- # + # decorators + # ----------------------------------------------------------------------- # + def implements_to_string(klass): + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda x: x.__unicode__().encode('utf-8') + return klass + + def is_installed(module): + import imp + try: + imp.find_module(module) + return True + except ImportError: + return False \ No newline at end of file diff --git a/modbus/pymodbus/constants.py b/modbus/pymodbus/constants.py new file mode 100644 index 00000000..175df038 --- /dev/null +++ b/modbus/pymodbus/constants.py @@ -0,0 +1,268 @@ +''' +Constants For Modbus Server/Client +---------------------------------- + +This is the single location for storing default +values for the servers and clients. +''' +from pymodbus.interfaces import Singleton + + +class Defaults(Singleton): + ''' A collection of modbus default values + + .. attribute:: Port + + The default modbus tcp server port (502) + + .. attribute:: TLSPort + + The default modbus tcp over tls server port (802) + + + .. attribute:: Backoff + + The default exponential backoff delay (0.3 seconds) + + .. attribute:: Retries + + The default number of times a client should retry the given + request before failing (3) + + .. attribute:: RetryOnEmpty + + A flag indicating if a transaction should be retried in the + case that an empty response is received. This is useful for + slow clients that may need more time to process a request. + + .. attribute:: RetryOnInvalid + + A flag indicating if a transaction should be retried in the + case that an invalid response is received. + + .. attribute:: Timeout + + The default amount of time a client should wait for a request + to be processed (3 seconds) + + .. attribute:: Reconnects + + The default number of times a client should attempt to reconnect + before deciding the server is down (0) + + .. attribute:: TransactionId + + The starting transaction identifier number (0) + + .. attribute:: ProtocolId + + The modbus protocol id. Currently this is set to 0 in all + but proprietary implementations. + + .. attribute:: UnitId + + The modbus slave addrss. Currently this is set to 0x00 which + means this request should be broadcast to all the slave devices + (really means that all the devices should respons). + + .. attribute:: Baudrate + + The speed at which the data is transmitted over the serial line. + This defaults to 19200. + + .. attribute:: Parity + + The type of checksum to use to verify data integrity. This can be + on of the following:: + + - (E)ven - 1 0 1 0 | P(0) + - (O)dd - 1 0 1 0 | P(1) + - (N)one - 1 0 1 0 | no parity + + This defaults to (N)one. + + .. attribute:: Bytesize + + The number of bits in a byte of serial data. This can be one of + 5, 6, 7, or 8. This defaults to 8. + + .. attribute:: Stopbits + + The number of bits sent after each character in a message to + indicate the end of the byte. This defaults to 1. + + .. attribute:: ZeroMode + + Indicates if the slave datastore should use indexing at 0 or 1. + More about this can be read in section 4.4 of the modbus specification. + + .. attribute:: IgnoreMissingSlaves + + In case a request is made to a missing slave, this defines if an error + should be returned or simply ignored. This is useful for the case of a + serial server emulater where a request to a non-existant slave on a bus + will never respond. The client in this case will simply timeout. + + .. attribute:: broadcast_enable + + When False unit_id 0 will be treated as any other unit_id. When True and + the unit_id is 0 the server will execute all requests on all server + contexts and not respond and the client will skip trying to receive a + response. Default value False does not conform to Modbus spec but maintains + legacy behavior for existing pymodbus users. + + ''' + Port = 502 + TLSPort = 802 + Backoff = 0.3 + Retries = 3 + RetryOnEmpty = False + RetryOnInvalid = False + Timeout = 3 + Reconnects = 0 + TransactionId = 0 + ProtocolId = 0 + UnitId = 0x00 + Baudrate = 19200 + Parity = 'N' + Bytesize = 8 + Stopbits = 1 + ZeroMode = False + IgnoreMissingSlaves = False + ReadSize = 1024 + broadcast_enable = False + +class ModbusStatus(Singleton): + ''' + These represent various status codes in the modbus + protocol. + + .. attribute:: Waiting + + This indicates that a modbus device is currently + waiting for a given request to finish some running task. + + .. attribute:: Ready + + This indicates that a modbus device is currently + free to perform the next request task. + + .. attribute:: On + + This indicates that the given modbus entity is on + + .. attribute:: Off + + This indicates that the given modbus entity is off + + .. attribute:: SlaveOn + + This indicates that the given modbus slave is running + + .. attribute:: SlaveOff + + This indicates that the given modbus slave is not running + ''' + Waiting = 0xffff + Ready = 0x0000 + On = 0xff00 + Off = 0x0000 + SlaveOn = 0xff + SlaveOff = 0x00 + + +class Endian(Singleton): + ''' An enumeration representing the various byte endianess. + + .. attribute:: Auto + + This indicates that the byte order is chosen by the + current native environment. + + .. attribute:: Big + + This indicates that the bytes are in little endian format + + .. attribute:: Little + + This indicates that the bytes are in big endian format + + .. note:: I am simply borrowing the format strings from the + python struct module for my convenience. + ''' + Auto = '@' + Big = '>' + Little = '<' + + +class ModbusPlusOperation(Singleton): + ''' Represents the type of modbus plus request + + .. attribute:: GetStatistics + + Operation requesting that the current modbus plus statistics + be returned in the response. + + .. attribute:: ClearStatistics + + Operation requesting that the current modbus plus statistics + be cleared and not returned in the response. + ''' + GetStatistics = 0x0003 + ClearStatistics = 0x0004 + + +class DeviceInformation(Singleton): + ''' Represents what type of device information to read + + .. attribute:: Basic + + This is the basic (required) device information to be returned. + This includes VendorName, ProductCode, and MajorMinorRevision + code. + + .. attribute:: Regular + + In addition to basic data objects, the device provides additional + and optional identification and description data objects. All of + the objects of this category are defined in the standard but their + implementation is optional. + + .. attribute:: Extended + + In addition to regular data objects, the device provides additional + and optional identification and description private data about the + physical device itself. All of these data are device dependent. + + .. attribute:: Specific + + Request to return a single data object. + ''' + Basic = 0x01 + Regular = 0x02 + Extended = 0x03 + Specific = 0x04 + + +class MoreData(Singleton): + ''' Represents the more follows condition + + .. attribute:: Nothing + + This indiates that no more objects are going to be returned. + + .. attribute:: KeepReading + + This indicates that there are more objects to be returned. + ''' + Nothing = 0x00 + KeepReading = 0xFF + +#---------------------------------------------------------------------------# +# Exported Identifiers +#---------------------------------------------------------------------------# +__all__ = [ + "Defaults", "ModbusStatus", "Endian", + "ModbusPlusOperation", + "DeviceInformation", "MoreData", +] diff --git a/modbus/pymodbus/datastore/__init__.py b/modbus/pymodbus/datastore/__init__.py new file mode 100644 index 00000000..a981d4be --- /dev/null +++ b/modbus/pymodbus/datastore/__init__.py @@ -0,0 +1,12 @@ +from pymodbus.datastore.store import ModbusSequentialDataBlock +from pymodbus.datastore.store import ModbusSparseDataBlock +from pymodbus.datastore.context import ModbusSlaveContext +from pymodbus.datastore.context import ModbusServerContext + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "ModbusSequentialDataBlock", "ModbusSparseDataBlock", + "ModbusSlaveContext", "ModbusServerContext", +] diff --git a/modbus/pymodbus/datastore/context.py b/modbus/pymodbus/datastore/context.py new file mode 100644 index 00000000..7ba7bf4a --- /dev/null +++ b/modbus/pymodbus/datastore/context.py @@ -0,0 +1,184 @@ +from pymodbus.exceptions import ParameterException, NoSuchSlaveException +from pymodbus.interfaces import IModbusSlaveContext +from pymodbus.datastore.store import ModbusSequentialDataBlock +from pymodbus.constants import Defaults +from pymodbus.compat import iteritems, itervalues + +#---------------------------------------------------------------------------# +# Logging +#---------------------------------------------------------------------------# +import logging +_logger = logging.getLogger(__name__) + + +#---------------------------------------------------------------------------# +# Slave Contexts +#---------------------------------------------------------------------------# +class ModbusSlaveContext(IModbusSlaveContext): + ''' + This creates a modbus data model with each data access + stored in its own personal block + ''' + + def __init__(self, *args, **kwargs): + ''' Initializes the datastores, defaults to fully populated + sequential data blocks if none are passed in. + + :param kwargs: Each element is a ModbusDataBlock + + 'di' - Discrete Inputs initializer + 'co' - Coils initializer + 'hr' - Holding Register initializer + 'ir' - Input Registers iniatializer + ''' + self.store = dict() + self.store['d'] = kwargs.get('di', ModbusSequentialDataBlock.create()) + self.store['c'] = kwargs.get('co', ModbusSequentialDataBlock.create()) + self.store['i'] = kwargs.get('ir', ModbusSequentialDataBlock.create()) + self.store['h'] = kwargs.get('hr', ModbusSequentialDataBlock.create()) + self.zero_mode = kwargs.get('zero_mode', Defaults.ZeroMode) + + def __str__(self): + ''' Returns a string representation of the context + + :returns: A string representation of the context + ''' + return "Modbus Slave Context" + + def reset(self): + ''' Resets all the datastores to their default values ''' + for datastore in itervalues(self.store): + datastore.reset() + + def validate(self, fx, address, count=1): + ''' Validates the request to make sure it is in range + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to test + :returns: True if the request in within range, False otherwise + ''' + if not self.zero_mode: + address = address + 1 + _logger.debug("validate: fc-[%d] address-%d: count-%d" % (fx, address, + count)) + return self.store[self.decode(fx)].validate(address, count) + + def getValues(self, fx, address, count=1): + ''' Get `count` values from datastore + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + ''' + if not self.zero_mode: + address = address + 1 + _logger.debug("getValues fc-[%d] address-%d: count-%d" % (fx, address, + count)) + return self.store[self.decode(fx)].getValues(address, count) + + def setValues(self, fx, address, values): + ''' Sets the datastore with the supplied values + + :param fx: The function we are working with + :param address: The starting address + :param values: The new values to be set + ''' + if not self.zero_mode: + address = address + 1 + _logger.debug("setValues[%d] %d:%d" % (fx, address, len(values))) + self.store[self.decode(fx)].setValues(address, values) + + def register(self, fc, fx, datablock=None): + """ + Registers a datablock with the slave context + :param fc: function code (int) + :param fx: string representation of function code (e.g 'cf' ) + :param datablock: datablock to associate with this function code + :return: + """ + self.store[fx] = datablock or ModbusSequentialDataBlock.create() + self._IModbusSlaveContext__fx_mapper[fc] = fx + + +class ModbusServerContext(object): + ''' This represents a master collection of slave contexts. + If single is set to true, it will be treated as a single + context so every unit-id returns the same context. If single + is set to false, it will be interpreted as a collection of + slave contexts. + ''' + + def __init__(self, slaves=None, single=True): + ''' Initializes a new instance of a modbus server context. + + :param slaves: A dictionary of client contexts + :param single: Set to true to treat this as a single context + ''' + self.single = single + self._slaves = slaves or {} + if self.single: + self._slaves = {Defaults.UnitId: self._slaves} + + def __iter__(self): + ''' Iterater over the current collection of slave + contexts. + + :returns: An iterator over the slave contexts + ''' + return iteritems(self._slaves) + + def __contains__(self, slave): + ''' Check if the given slave is in this list + + :param slave: slave The slave to check for existence + :returns: True if the slave exists, False otherwise + ''' + if self.single and self._slaves: + return True + else: + return slave in self._slaves + + def __setitem__(self, slave, context): + ''' Used to set a new slave context + + :param slave: The slave context to set + :param context: The new context to set for this slave + ''' + if self.single: + slave = Defaults.UnitId + if 0xf7 >= slave >= 0x00: + self._slaves[slave] = context + else: + raise NoSuchSlaveException('slave index :{} ' + 'out of range'.format(slave)) + + def __delitem__(self, slave): + ''' Wrapper used to access the slave context + + :param slave: The slave context to remove + ''' + if not self.single and (0xf7 >= slave >= 0x00): + del self._slaves[slave] + else: + raise NoSuchSlaveException('slave index: {} ' + 'out of range'.format(slave)) + + def __getitem__(self, slave): + ''' Used to get access to a slave context + + :param slave: The slave context to get + :returns: The requested slave context + ''' + if self.single: + slave = Defaults.UnitId + if slave in self._slaves: + return self._slaves.get(slave) + else: + raise NoSuchSlaveException("slave - {} does not exist, " + "or is out of range".format(slave)) + + def slaves(self): + # Python3 now returns keys() as iterable + return list(self._slaves.keys()) diff --git a/modbus/pymodbus/datastore/database/__init__.py b/modbus/pymodbus/datastore/database/__init__.py new file mode 100644 index 00000000..dbb2609a --- /dev/null +++ b/modbus/pymodbus/datastore/database/__init__.py @@ -0,0 +1,7 @@ +from pymodbus.datastore.database.sql_datastore import SqlSlaveContext +from pymodbus.datastore.database.redis_datastore import RedisSlaveContext + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = ["SqlSlaveContext", "RedisSlaveContext"] diff --git a/modbus/pymodbus/datastore/database/redis_datastore.py b/modbus/pymodbus/datastore/database/redis_datastore.py new file mode 100644 index 00000000..98bf5bef --- /dev/null +++ b/modbus/pymodbus/datastore/database/redis_datastore.py @@ -0,0 +1,243 @@ +import redis +from pymodbus.interfaces import IModbusSlaveContext +from pymodbus.utilities import pack_bitstring, unpack_bitstring + +#---------------------------------------------------------------------------# +# Logging +#---------------------------------------------------------------------------# +import logging +_logger = logging.getLogger(__name__) + + +#---------------------------------------------------------------------------# +# Context +#---------------------------------------------------------------------------# +class RedisSlaveContext(IModbusSlaveContext): + ''' + This is a modbus slave context using redis as a backing + store. + ''' + + def __init__(self, **kwargs): + ''' Initializes the datastores + + :param host: The host to connect to + :param port: The port to connect to + :param prefix: A prefix for the keys + ''' + host = kwargs.get('host', 'localhost') + port = kwargs.get('port', 6379) + self.prefix = kwargs.get('prefix', 'pymodbus') + self.client = kwargs.get('client', redis.Redis(host=host, port=port)) + self._build_mapping() + + def __str__(self): + ''' Returns a string representation of the context + + :returns: A string representation of the context + ''' + return "Redis Slave Context %s" % self.client + + def reset(self): + ''' Resets all the datastores to their default values ''' + self.client.flushall() + + def validate(self, fx, address, count=1): + ''' Validates the request to make sure it is in range + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to test + :returns: True if the request in within range, False otherwise + ''' + address = address + 1 # section 4.4 of specification + _logger.debug("validate[%d] %d:%d" % (fx, address, count)) + return self._val_callbacks[self.decode(fx)](address, count) + + def getValues(self, fx, address, count=1): + ''' Get `count` values from datastore + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + ''' + address = address + 1 # section 4.4 of specification + _logger.debug("getValues[%d] %d:%d" % (fx, address, count)) + return self._get_callbacks[self.decode(fx)](address, count) + + def setValues(self, fx, address, values): + ''' Sets the datastore with the supplied values + + :param fx: The function we are working with + :param address: The starting address + :param values: The new values to be set + ''' + address = address + 1 # section 4.4 of specification + _logger.debug("setValues[%d] %d:%d" % (fx, address, len(values))) + self._set_callbacks[self.decode(fx)](address, values) + + #--------------------------------------------------------------------------# + # Redis Helper Methods + #--------------------------------------------------------------------------# + def _get_prefix(self, key): + ''' This is a helper to abstract getting bit values + + :param key: The key prefix to use + :returns: The key prefix to redis + ''' + return "%s:%s" % (self.prefix, key) + + def _build_mapping(self): + ''' + A quick helper method to build the function + code mapper. + ''' + self._val_callbacks = { + 'd': lambda o, c: self._val_bit('d', o, c), + 'c': lambda o, c: self._val_bit('c', o, c), + 'h': lambda o, c: self._val_reg('h', o, c), + 'i': lambda o, c: self._val_reg('i', o, c), + } + self._get_callbacks = { + 'd': lambda o, c: self._get_bit('d', o, c), + 'c': lambda o, c: self._get_bit('c', o, c), + 'h': lambda o, c: self._get_reg('h', o, c), + 'i': lambda o, c: self._get_reg('i', o, c), + } + self._set_callbacks = { + 'd': lambda o, v: self._set_bit('d', o, v), + 'c': lambda o, v: self._set_bit('c', o, v), + 'h': lambda o, v: self._set_reg('h', o, v), + 'i': lambda o, v: self._set_reg('i', o, v), + } + + #--------------------------------------------------------------------------# + # Redis discrete implementation + #--------------------------------------------------------------------------# + _bit_size = 16 + _bit_default = '\x00' * (_bit_size % 8) + + def _get_bit_values(self, key, offset, count): + ''' This is a helper to abstract getting bit values + + :param key: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + ''' + key = self._get_prefix(key) + s = divmod(offset, self._bit_size)[0] + e = divmod(offset + count, self._bit_size)[0] + + request = ('%s:%s' % (key, v) for v in range(s, e + 1)) + response = self.client.mget(request) + return response + + def _val_bit(self, key, offset, count): + ''' Validates that the given range is currently set in redis. + If any of the keys return None, then it is invalid. + + :param key: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + ''' + response = self._get_bit_values(key, offset, count) + return True if None not in response else False + + def _get_bit(self, key, offset, count): + ''' + + :param key: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + ''' + response = self._get_bit_values(key, offset, count) + response = (r or self._bit_default for r in response) + result = ''.join(response) + result = unpack_bitstring(result) + return result[offset:offset + count] + + def _set_bit(self, key, offset, values): + ''' + + :param key: The key prefix to use + :param offset: The address offset to start at + :param values: The values to set + ''' + count = len(values) + s = divmod(offset, self._bit_size)[0] + e = divmod(offset + count, self._bit_size)[0] + value = pack_bitstring(values) + + current = self._get_bit_values(key, offset, count) + current = (r or self._bit_default for r in current) + current = ''.join(current) + current = current[0:offset] + value.decode('utf-8') + current[offset + count:] + final = (current[s:s + self._bit_size] for s in range(0, count, self._bit_size)) + + key = self._get_prefix(key) + request = ('%s:%s' % (key, v) for v in range(s, e + 1)) + request = dict(zip(request, final)) + self.client.mset(request) + + #--------------------------------------------------------------------------# + # Redis register implementation + #--------------------------------------------------------------------------# + _reg_size = 16 + _reg_default = '\x00' * (_reg_size % 8) + + def _get_reg_values(self, key, offset, count): + ''' This is a helper to abstract getting register values + + :param key: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + ''' + key = self._get_prefix(key) + #s = divmod(offset, self.__reg_size)[0] + #e = divmod(offset+count, self.__reg_size)[0] + + #request = ('%s:%s' % (key, v) for v in range(s, e + 1)) + request = ('%s:%s' % (key, v) for v in range(offset, count + 1)) + response = self.client.mget(request) + return response + + def _val_reg(self, key, offset, count): + ''' Validates that the given range is currently set in redis. + If any of the keys return None, then it is invalid. + + :param key: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + ''' + response = self._get_reg_values(key, offset, count) + return None not in response + + def _get_reg(self, key, offset, count): + ''' + + :param key: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + ''' + response = self._get_reg_values(key, offset, count) + response = [r or self._reg_default for r in response] + return response[offset:offset + count] + + def _set_reg(self, key, offset, values): + ''' + + :param key: The key prefix to use + :param offset: The address offset to start at + :param values: The values to set + ''' + count = len(values) + #s = divmod(offset, self.__reg_size) + #e = divmod(offset+count, self.__reg_size) + + #current = self.__get_reg_values(key, offset, count) + + key = self._get_prefix(key) + request = ('%s:%s' % (key, v) for v in range(offset, count + 1)) + request = dict(zip(request, values)) + self.client.mset(request) diff --git a/modbus/pymodbus/datastore/database/sql_datastore.py b/modbus/pymodbus/datastore/database/sql_datastore.py new file mode 100644 index 00000000..769dab75 --- /dev/null +++ b/modbus/pymodbus/datastore/database/sql_datastore.py @@ -0,0 +1,184 @@ +import sqlalchemy +import sqlalchemy.types as sqltypes +from sqlalchemy.sql import and_ +from sqlalchemy.schema import UniqueConstraint +from sqlalchemy.sql.expression import bindparam + +from pymodbus.exceptions import NotImplementedException +from pymodbus.interfaces import IModbusSlaveContext + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Context +# --------------------------------------------------------------------------- # +class SqlSlaveContext(IModbusSlaveContext): + """ + This creates a modbus data model with each data access + stored in its own personal block + """ + + def __init__(self, *args, **kwargs): + """ Initializes the datastores + + :param kwargs: Each element is a ModbusDataBlock + """ + self.table = kwargs.get('table', 'pymodbus') + self.database = kwargs.get('database', 'sqlite:///pymodbus.db') + self._db_create(self.table, self.database) + + def __str__(self): + """ Returns a string representation of the context + + :returns: A string representation of the context + """ + return "Modbus Slave Context" + + def reset(self): + """ Resets all the datastores to their default values """ + self._metadata.drop_all() + self._db_create(self.table, self.database) + + def validate(self, fx, address, count=1): + """ Validates the request to make sure it is in range + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to test + :returns: True if the request in within range, False otherwise + """ + address = address + 1 # section 4.4 of specification + _logger.debug("validate[%d] %d:%d" % (fx, address, count)) + return self._validate(self.decode(fx), address, count) + + def getValues(self, fx, address, count=1): + """ Get `count` values from datastore + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + """ + address = address + 1 # section 4.4 of specification + _logger.debug("get-values[%d] %d:%d" % (fx, address, count)) + return self._get(self.decode(fx), address, count) + + def setValues(self, fx, address, values, update=True): + """ Sets the datastore with the supplied values + + :param fx: The function we are working with + :param address: The starting address + :param values: The new values to be set + :param update: Update existing register in the db + """ + address = address + 1 # section 4.4 of specification + _logger.debug("set-values[%d] %d:%d" % (fx, address, len(values))) + if update: + self._update(self.decode(fx), address, values) + else: + self._set(self.decode(fx), address, values) + + # ----------------------------------------------------------------------- # + # Sqlite Helper Methods + # ----------------------------------------------------------------------- # + def _db_create(self, table, database): + """ A helper method to initialize the database and handles + + :param table: The table name to create + :param database: The database uri to use + """ + self._engine = sqlalchemy.create_engine(database, echo=False) + self._metadata = sqlalchemy.MetaData(self._engine) + self._table = sqlalchemy.Table(table, self._metadata, + sqlalchemy.Column('type', sqltypes.String(1)), + sqlalchemy.Column('index', sqltypes.Integer), + sqlalchemy.Column('value', sqltypes.Integer), + UniqueConstraint('type', 'index', name='key')) + self._table.create(checkfirst=True) + self._connection = self._engine.connect() + + def _get(self, type, offset, count): + """ + :param type: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + :returns: The resulting values + """ + query = self._table.select(and_( + self._table.c.type == type, + self._table.c.index >= offset, + self._table.c.index <= offset + count - 1) + ) + query = query.order_by(self._table.c.index.asc()) + result = self._connection.execute(query).fetchall() + return [row.value for row in result] + + def _build_set(self, type, offset, values, prefix=''): + """ A helper method to generate the sql update context + + :param type: The key prefix to use + :param offset: The address offset to start at + :param values: The values to set + :param prefix: Prefix fields index and type, defaults to empty string + """ + result = [] + for index, value in enumerate(values): + result.append({ + prefix + 'type': type, + prefix + 'index': offset + index, + 'value': value + }) + return result + + def _check(self, type, offset, values): + result = self._get(type, offset, count=1) + return False if len(result) > 0 else True + + def _set(self, type, offset, values): + """ + + :param key: The type prefix to use + :param offset: The address offset to start at + :param values: The values to set + """ + if self._check(type, offset, values): + context = self._build_set(type, offset, values) + query = self._table.insert() + result = self._connection.execute(query, context) + return result.rowcount == len(values) + else: + return False + + def _update(self, type, offset, values): + """ + + :param type: The type prefix to use + :param offset: The address offset to start at + :param values: The values to set + """ + context = self._build_set(type, offset, values, prefix='x_') + query = self._table.update().values(value='value') + query = query.where(and_( + self._table.c.type == bindparam('x_type'), + self._table.c.index == bindparam('x_index'))) + result = self._connection.execute(query, context) + return result.rowcount == len(values) + + def _validate(self, type, offset, count): + """ + :param key: The key prefix to use + :param offset: The address offset to start at + :param count: The number of bits to read + :returns: The result of the validation + """ + query = self._table.select(and_( + self._table.c.type == type, + self._table.c.index >= offset, + self._table.c.index <= offset + count - 1)) + result = self._connection.execute(query).fetchall() + return len(result) == count diff --git a/modbus/pymodbus/datastore/remote.py b/modbus/pymodbus/datastore/remote.py new file mode 100644 index 00000000..17161cc7 --- /dev/null +++ b/modbus/pymodbus/datastore/remote.py @@ -0,0 +1,108 @@ +from pymodbus.exceptions import NotImplementedException +from pymodbus.interfaces import IModbusSlaveContext + +#---------------------------------------------------------------------------# +# Logging +#---------------------------------------------------------------------------# +import logging +_logger = logging.getLogger(__name__) + + +#---------------------------------------------------------------------------# +# Context +#---------------------------------------------------------------------------# +class RemoteSlaveContext(IModbusSlaveContext): + ''' TODO + This creates a modbus data model that connects to + a remote device (depending on the client used) + ''' + + def __init__(self, client, unit=None): + ''' Initializes the datastores + + :param client: The client to retrieve values with + :param unit: Unit ID of the remote slave + ''' + self._client = client + self.unit = unit + self.__build_mapping() + + def reset(self): + ''' Resets all the datastores to their default values ''' + raise NotImplementedException() + + def validate(self, fx, address, count=1): + ''' Validates the request to make sure it is in range + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to test + :returns: True if the request in within range, False otherwise + ''' + _logger.debug("validate[%d] %d:%d" % (fx, address, count)) + result = self.__get_callbacks[self.decode(fx)](address, count) + return not result.isError() + + def getValues(self, fx, address, count=1): + ''' Get `count` values from datastore + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + ''' + # TODO deal with deferreds + _logger.debug("get values[%d] %d:%d" % (fx, address, count)) + result = self.__get_callbacks[self.decode(fx)](address, count) + return self.__extract_result(self.decode(fx), result) + + def setValues(self, fx, address, values): + ''' Sets the datastore with the supplied values + + :param fx: The function we are working with + :param address: The starting address + :param values: The new values to be set + ''' + # TODO deal with deferreds + _logger.debug("set values[%d] %d:%d" % (fx, address, len(values))) + self.__set_callbacks[self.decode(fx)](address, values) + + def __str__(self): + ''' Returns a string representation of the context + + :returns: A string representation of the context + ''' + return "Remote Slave Context(%s)" % self._client + + def __build_mapping(self): + ''' + A quick helper method to build the function + code mapper. + ''' + kwargs = {} + if self.unit: + kwargs["unit"] = self.unit + self.__get_callbacks = { + 'd': lambda a, c: self._client.read_discrete_inputs(a, c, **kwargs), + 'c': lambda a, c: self._client.read_coils(a, c, **kwargs), + 'h': lambda a, c: self._client.read_holding_registers(a, c, **kwargs), + 'i': lambda a, c: self._client.read_input_registers(a, c, **kwargs), + } + self.__set_callbacks = { + 'd': lambda a, v: self._client.write_coils(a, v, **kwargs), + 'c': lambda a, v: self._client.write_coils(a, v, **kwargs), + 'h': lambda a, v: self._client.write_registers(a, v, **kwargs), + 'i': lambda a, v: self._client.write_registers(a, v, **kwargs), + } + + def __extract_result(self, fx, result): + ''' A helper method to extract the values out of + a response. TODO make this consistent (values?) + ''' + if not result.isError(): + if fx in ['d', 'c']: + return result.bits + if fx in ['h', 'i']: + return result.registers + else: + return result diff --git a/modbus/pymodbus/datastore/store.py b/modbus/pymodbus/datastore/store.py new file mode 100644 index 00000000..3f457677 --- /dev/null +++ b/modbus/pymodbus/datastore/store.py @@ -0,0 +1,313 @@ +""" +Modbus Server Datastore +------------------------- + +For each server, you will create a ModbusServerContext and pass +in the default address space for each data access. The class +will create and manage the data. + +Further modification of said data accesses should be performed +with [get,set][access]Values(address, count) + +Datastore Implementation +------------------------- + +There are two ways that the server datastore can be implemented. +The first is a complete range from 'address' start to 'count' +number of indecies. This can be thought of as a straight array:: + + data = range(1, 1 + count) + [1,2,3,...,count] + +The other way that the datastore can be implemented (and how +many devices implement it) is a associate-array:: + + data = {1:'1', 3:'3', ..., count:'count'} + [1,3,...,count] + +The difference between the two is that the latter will allow +arbitrary gaps in its datastore while the former will not. +This is seen quite commonly in some modbus implementations. +What follows is a clear example from the field: + +Say a company makes two devices to monitor power usage on a rack. +One works with three-phase and the other with a single phase. The +company will dictate a modbus data mapping such that registers:: + + n: phase 1 power + n+1: phase 2 power + n+2: phase 3 power + +Using this, layout, the first device will implement n, n+1, and n+2, +however, the second device may set the latter two values to 0 or +will simply not implmented the registers thus causing a single read +or a range read to fail. + +I have both methods implemented, and leave it up to the user to change +based on their preference. +""" +from pymodbus.exceptions import NotImplementedException, ParameterException +from pymodbus.compat import iteritems, iterkeys, itervalues, get_next + +#---------------------------------------------------------------------------# +# Logging +#---------------------------------------------------------------------------# +import logging +_logger = logging.getLogger(__name__) + + +#---------------------------------------------------------------------------# +# Datablock Storage +#---------------------------------------------------------------------------# +class BaseModbusDataBlock(object): + ''' + Base class for a modbus datastore + + Derived classes must create the following fields: + @address The starting address point + @defult_value The default value of the datastore + @values The actual datastore values + + Derived classes must implemented the following methods: + validate(self, address, count=1) + getValues(self, address, count=1) + setValues(self, address, values) + ''' + + def default(self, count, value=False): + ''' Used to initialize a store to one value + + :param count: The number of fields to set + :param value: The default value to set to the fields + ''' + self.default_value = value + self.values = [self.default_value] * count + self.address = 0x00 + + def reset(self): + ''' Resets the datastore to the initialized default value ''' + self.values = [self.default_value] * len(self.values) + + def validate(self, address, count=1): + ''' Checks to see if the request is in range + + :param address: The starting address + :param count: The number of values to test for + :returns: True if the request in within range, False otherwise + ''' + raise NotImplementedException("Datastore Address Check") + + def getValues(self, address, count=1): + ''' Returns the requested values from the datastore + + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + ''' + raise NotImplementedException("Datastore Value Retrieve") + + def setValues(self, address, values): + ''' Returns the requested values from the datastore + + :param address: The starting address + :param values: The values to store + ''' + raise NotImplementedException("Datastore Value Retrieve") + + def __str__(self): + ''' Build a representation of the datastore + + :returns: A string representation of the datastore + ''' + return "DataStore(%d, %d)" % (len(self.values), self.default_value) + + def __iter__(self): + ''' Iterater over the data block data + + :returns: An iterator of the data block data + ''' + if isinstance(self.values, dict): + return iteritems(self.values) + return enumerate(self.values, self.address) + + +class ModbusSequentialDataBlock(BaseModbusDataBlock): + ''' Creates a sequential modbus datastore ''' + + def __init__(self, address, values): + ''' Initializes the datastore + + :param address: The starting address of the datastore + :param values: Either a list or a dictionary of values + ''' + self.address = address + if hasattr(values, '__iter__'): + self.values = list(values) + else: + self.values = [values] + self.default_value = self.values[0].__class__() + + @classmethod + def create(klass): + ''' Factory method to create a datastore with the + full address space initialized to 0x00 + + :returns: An initialized datastore + ''' + return klass(0x00, [0x00] * 65536) + + def validate(self, address, count=1): + ''' Checks to see if the request is in range + + :param address: The starting address + :param count: The number of values to test for + :returns: True if the request in within range, False otherwise + ''' + result = (self.address <= address) + result &= ((self.address + len(self.values)) >= (address + count)) + return result + + def getValues(self, address, count=1): + ''' Returns the requested values of the datastore + + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + ''' + start = address - self.address + return self.values[start:start + count] + + def setValues(self, address, values): + ''' Sets the requested values of the datastore + + :param address: The starting address + :param values: The new values to be set + ''' + if not isinstance(values, list): + values = [values] + start = address - self.address + self.values[start:start + len(values)] = values + + +class ModbusSparseDataBlock(BaseModbusDataBlock): + """ + Creates a sparse modbus datastore + + E.g Usage. + sparse = ModbusSparseDataBlock({10: [3, 5, 6, 8], 30: 1, 40: [0]*20}) + + This would create a datablock with 3 blocks starting at + offset 10 with length 4 , 30 with length 1 and 40 with length 20 + + sparse = ModbusSparseDataBlock([10]*100) + Creates a sparse datablock of length 100 starting at offset 0 and default value of 10 + + sparse = ModbusSparseDataBlock() --> Create Empty datablock + sparse.setValues(0, [10]*10) --> Add block 1 at offset 0 with length 10 (default value 10) + sparse.setValues(30, [20]*5) --> Add block 2 at offset 30 with length 5 (default value 20) + + if mutable is set to True during initialization, the datablock can not be altered with + setValues (new datablocks can not be added) + """ + + def __init__(self, values=None, mutable=True): + """ + Initializes a sparse datastore. Will only answer to addresses + registered, either initially here, or later via setValues() + + :param values: Either a list or a dictionary of values + :param mutable: The data-block can be altered later with setValues(i.e add more blocks) + + If values are list , This is as good as sequential datablock. + Values as dictionary should be in {offset: } format, if values + is a list, a sparse datablock is created starting at offset with the length of values. + If values is a integer, then the value is set for the corresponding offset. + + """ + self.values = {} + self._process_values(values) + self.mutable = mutable + self.default_value = self.values.copy() + self.address = get_next(iterkeys(self.values), None) + + @classmethod + def create(klass, values=None): + ''' Factory method to create sparse datastore. + Use setValues to initialize registers. + + :param values: Either a list or a dictionary of values + :returns: An initialized datastore + ''' + return klass(values) + + def reset(self): + ''' Reset the store to the initially provided defaults''' + self.values = self.default_value.copy() + + def validate(self, address, count=1): + ''' Checks to see if the request is in range + + :param address: The starting address + :param count: The number of values to test for + :returns: True if the request in within range, False otherwise + ''' + if count == 0: + return False + handle = set(range(address, address + count)) + return handle.issubset(set(iterkeys(self.values))) + + def getValues(self, address, count=1): + ''' Returns the requested values of the datastore + + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + ''' + return [self.values[i] for i in range(address, address + count)] + + def _process_values(self, values): + def _process_as_dict(values): + for idx, val in iteritems(values): + if isinstance(val, (list, tuple)): + for i, v in enumerate(val): + self.values[idx + i] = v + else: + self.values[idx] = int(val) + if isinstance(values, dict): + _process_as_dict(values) + return + if hasattr(values, '__iter__'): + values = dict(enumerate(values)) + elif values is None: + values = {} # Must make a new dict here per instance + else: + raise ParameterException("Values for datastore must " + "be a list or dictionary") + _process_as_dict(values) + + def setValues(self, address, values, use_as_default=False): + ''' Sets the requested values of the datastore + + :param address: The starting address + :param values: The new values to be set + :param use_as_default: Use the values as default + ''' + if isinstance(values, dict): + new_offsets = list(set(list(values.keys())) - set(list(self.values.keys()))) + if new_offsets and not self.mutable: + raise ParameterException("Offsets {} not " + "in range".format(new_offsets)) + self._process_values(values) + else: + if not isinstance(values, list): + values = [values] + for idx, val in enumerate(values): + if address+idx not in self.values and not self.mutable: + raise ParameterException("Offset {} not " + "in range".format(address+idx)) + self.values[address + idx] = val + if not self.address: + self.address = get_next(iterkeys(self.values), None) + if use_as_default: + for idx, val in iteritems(self.values): + self.default_value[idx] = val diff --git a/modbus/pymodbus/device.py b/modbus/pymodbus/device.py new file mode 100644 index 00000000..829abe76 --- /dev/null +++ b/modbus/pymodbus/device.py @@ -0,0 +1,626 @@ +""" +Modbus Device Controller +------------------------- + +These are the device management handlers. They should be +maintained in the server context and the various methods +should be inserted in the correct locations. +""" +from pymodbus.constants import DeviceInformation +from pymodbus.interfaces import Singleton +from pymodbus.utilities import dict_property +from pymodbus.compat import iteritems, itervalues, izip, int2byte + +from collections import OrderedDict + +#---------------------------------------------------------------------------# +# Network Access Control +#---------------------------------------------------------------------------# +class ModbusAccessControl(Singleton): + ''' + This is a simple implementation of a Network Management System table. + Its purpose is to control access to the server (if it is used). + We assume that if an entry is in the table, it is allowed accesses to + resources. However, if the host does not appear in the table (all + unknown hosts) its connection will simply be closed. + + Since it is a singleton, only one version can possible exist and all + instances pull from here. + ''' + __nmstable = [ + "127.0.0.1", + ] + + def __iter__(self): + ''' Iterater over the network access table + + :returns: An iterator of the network access table + ''' + return self.__nmstable.__iter__() + + def __contains__(self, host): + ''' Check if a host is allowed to access resources + + :param host: The host to check + ''' + return host in self.__nmstable + + def add(self, host): + ''' Add allowed host(s) from the NMS table + + :param host: The host to add + ''' + if not isinstance(host, list): + host = [host] + for entry in host: + if entry not in self.__nmstable: + self.__nmstable.append(entry) + + def remove(self, host): + ''' Remove allowed host(s) from the NMS table + + :param host: The host to remove + ''' + if not isinstance(host, list): + host = [host] + for entry in host: + if entry in self.__nmstable: + self.__nmstable.remove(entry) + + def check(self, host): + ''' Check if a host is allowed to access resources + + :param host: The host to check + ''' + return host in self.__nmstable + + +#---------------------------------------------------------------------------# +# Modbus Plus Statistics +#---------------------------------------------------------------------------# +class ModbusPlusStatistics(object): + ''' + This is used to maintain the current modbus plus statistics count. As of + right now this is simply a stub to complete the modbus implementation. + For more information, see the modbus implementation guide page 87. + ''' + + __data = OrderedDict({ + 'node_type_id' : [0x00] * 2, # 00 + 'software_version_number' : [0x00] * 2, # 01 + 'network_address' : [0x00] * 2, # 02 + 'mac_state_variable' : [0x00] * 2, # 03 + 'peer_status_code' : [0x00] * 2, # 04 + 'token_pass_counter' : [0x00] * 2, # 05 + 'token_rotation_time' : [0x00] * 2, # 06 + + 'program_master_token_failed' : [0x00], # 07 hi + 'data_master_token_failed' : [0x00], # 07 lo + 'program_master_token_owner' : [0x00], # 08 hi + 'data_master_token_owner' : [0x00], # 08 lo + 'program_slave_token_owner' : [0x00], # 09 hi + 'data_slave_token_owner' : [0x00], # 09 lo + 'data_slave_command_transfer' : [0x00], # 10 hi + '__unused_10_lowbit' : [0x00], # 10 lo + + 'program_slave_command_transfer' : [0x00], # 11 hi + 'program_master_rsp_transfer' : [0x00], # 11 lo + 'program_slave_auto_logout' : [0x00], # 12 hi + 'program_master_connect_status' : [0x00], # 12 lo + 'receive_buffer_dma_overrun' : [0x00], # 13 hi + 'pretransmit_deferral_error' : [0x00], # 13 lo + 'frame_size_error' : [0x00], # 14 hi + 'repeated_command_received' : [0x00], # 14 lo + 'receiver_alignment_error' : [0x00], # 15 hi + 'receiver_collision_abort_error' : [0x00], # 15 lo + 'bad_packet_length_error' : [0x00], # 16 hi + 'receiver_crc_error' : [0x00], # 16 lo + 'transmit_buffer_dma_underrun' : [0x00], # 17 hi + 'bad_link_address_error' : [0x00], # 17 lo + + 'bad_mac_function_code_error' : [0x00], # 18 hi + 'internal_packet_length_error' : [0x00], # 18 lo + 'communication_failed_error' : [0x00], # 19 hi + 'communication_retries' : [0x00], # 19 lo + 'no_response_error' : [0x00], # 20 hi + 'good_receive_packet' : [0x00], # 20 lo + 'unexpected_path_error' : [0x00], # 21 hi + 'exception_response_error' : [0x00], # 21 lo + 'forgotten_transaction_error' : [0x00], # 22 hi + 'unexpected_response_error' : [0x00], # 22 lo + + 'active_station_bit_map' : [0x00] * 8, # 23-26 + 'token_station_bit_map' : [0x00] * 8, # 27-30 + 'global_data_bit_map' : [0x00] * 8, # 31-34 + 'receive_buffer_use_bit_map' : [0x00] * 8, # 35-37 + 'data_master_output_path' : [0x00] * 8, # 38-41 + 'data_slave_input_path' : [0x00] * 8, # 42-45 + 'program_master_outptu_path' : [0x00] * 8, # 46-49 + 'program_slave_input_path' : [0x00] * 8, # 50-53 + }) + + def __init__(self): + ''' + Initialize the modbus plus statistics with the default + information. + ''' + self.reset() + + def __iter__(self): + ''' Iterater over the statistics + + :returns: An iterator of the modbus plus statistics + ''' + return iteritems(self.__data) + + def reset(self): + ''' This clears all of the modbus plus statistics + ''' + for key in self.__data: + self.__data[key] = [0x00] * len(self.__data[key]) + + def summary(self): + ''' Returns a summary of the modbus plus statistics + + :returns: 54 16-bit words representing the status + ''' + return itervalues(self.__data) + + def encode(self): + ''' Returns a summary of the modbus plus statistics + + :returns: 54 16-bit words representing the status + ''' + total, values = [], sum(self.__data.values(), []) + for c in range(0, len(values), 2): + total.append((values[c] << 8) | values[c+1]) + return total + + +#---------------------------------------------------------------------------# +# Device Information Control +#---------------------------------------------------------------------------# +class ModbusDeviceIdentification(object): + ''' + This is used to supply the device identification + for the readDeviceIdentification function + + For more information read section 6.21 of the modbus + application protocol. + ''' + __data = { + 0x00: '', # VendorName + 0x01: '', # ProductCode + 0x02: '', # MajorMinorRevision + 0x03: '', # VendorUrl + 0x04: '', # ProductName + 0x05: '', # ModelName + 0x06: '', # UserApplicationName + 0x07: '', # reserved + 0x08: '', # reserved + # 0x80 -> 0xFF are private + } + + __names = [ + 'VendorName', + 'ProductCode', + 'MajorMinorRevision', + 'VendorUrl', + 'ProductName', + 'ModelName', + 'UserApplicationName', + ] + + def __init__(self, info=None): + ''' + Initialize the datastore with the elements you need. + (note acceptable range is [0x00-0x06,0x80-0xFF] inclusive) + + :param information: A dictionary of {int:string} of values + ''' + if isinstance(info, dict): + for key in info: + if (0x06 >= key >= 0x00) or (0xFF >= key >= 0x80): + self.__data[key] = info[key] + + def __iter__(self): + ''' Iterater over the device information + + :returns: An iterator of the device information + ''' + return iteritems(self.__data) + + def summary(self): + ''' Return a summary of the main items + + :returns: An dictionary of the main items + ''' + return dict(zip(self.__names, itervalues(self.__data))) + + def update(self, value): + ''' Update the values of this identity + using another identify as the value + + :param value: The value to copy values from + ''' + self.__data.update(value) + + def __setitem__(self, key, value): + ''' Wrapper used to access the device information + + :param key: The register to set + :param value: The new value for referenced register + ''' + if key not in [0x07, 0x08]: + self.__data[key] = value + + def __getitem__(self, key): + ''' Wrapper used to access the device information + + :param key: The register to read + ''' + return self.__data.setdefault(key, '') + + def __str__(self): + ''' Build a representation of the device + + :returns: A string representation of the device + ''' + return "DeviceIdentity" + + #-------------------------------------------------------------------------# + # Properties + #-------------------------------------------------------------------------# + VendorName = dict_property(lambda s: s.__data, 0) + ProductCode = dict_property(lambda s: s.__data, 1) + MajorMinorRevision = dict_property(lambda s: s.__data, 2) + VendorUrl = dict_property(lambda s: s.__data, 3) + ProductName = dict_property(lambda s: s.__data, 4) + ModelName = dict_property(lambda s: s.__data, 5) + UserApplicationName = dict_property(lambda s: s.__data, 6) + + +class DeviceInformationFactory(Singleton): + ''' This is a helper factory that really just hides + some of the complexity of processing the device information + requests (function code 0x2b 0x0e). + ''' + + __lookup = { + DeviceInformation.Basic: lambda c, r, i: c.__gets(r, list(range(i, 0x03))), + DeviceInformation.Regular: lambda c, r, i: c.__gets(r, list(range(i, 0x07)) + if c.__get(r, i)[i] else list(range(0, 0x07))), + DeviceInformation.Extended: lambda c, r, i: c.__gets(r, + [x for x in range(i, 0x100) if x not in range(0x07, 0x80)] + if c.__get(r, i)[i] else + [x for x in range(0, 0x100) if x not in range(0x07, 0x80)]), + DeviceInformation.Specific: lambda c, r, i: c.__get(r, i), + } + + @classmethod + def get(cls, control, read_code=DeviceInformation.Basic, object_id=0x00): + ''' Get the requested device data from the system + + :param control: The control block to pull data from + :param read_code: The read code to process + :param object_id: The specific object_id to read + :returns: The requested data (id, length, value) + ''' + identity = control.Identity + return cls.__lookup[read_code](cls, identity, object_id) + + @classmethod + def __get(cls, identity, object_id): + ''' Read a single object_id from the device information + + :param identity: The identity block to pull data from + :param object_id: The specific object id to read + :returns: The requested data (id, length, value) + ''' + return { object_id:identity[object_id] } + + @classmethod + def __gets(cls, identity, object_ids): + ''' Read multiple object_ids from the device information + + :param identity: The identity block to pull data from + :param object_ids: The specific object ids to read + :returns: The requested data (id, length, value) + ''' + return dict((oid, identity[oid]) for oid in object_ids if identity[oid]) + + +#---------------------------------------------------------------------------# +# Counters Handler +#---------------------------------------------------------------------------# +class ModbusCountersHandler(object): + ''' + This is a helper class to simplify the properties for the counters:: + + 0x0B 1 Return Bus Message Count + + Quantity of messages that the remote + device has detected on the communications system since its + last restart, clear counters operation, or power-up. Messages + with bad CRC are not taken into account. + + 0x0C 2 Return Bus Communication Error Count + + Quantity of CRC errors encountered by the remote device since its + last restart, clear counters operation, or power-up. In case of + an error detected on the character level, (overrun, parity error), + or in case of a message length < 3 bytes, the receiving device is + not able to calculate the CRC. In such cases, this counter is + also incremented. + + 0x0D 3 Return Slave Exception Error Count + + Quantity of MODBUS exception error detected by the remote device + since its last restart, clear counters operation, or power-up. It + comprises also the error detected in broadcast messages even if an + exception message is not returned in this case. + Exception errors are described and listed in "MODBUS Application + Protocol Specification" document. + + 0xOE 4 Return Slave Message Count + + Quantity of messages addressed to the remote device, including + broadcast messages, that the remote device has processed since its + last restart, clear counters operation, or power-up. + + 0x0F 5 Return Slave No Response Count + + Quantity of messages received by the remote device for which it + returned no response (neither a normal response nor an exception + response), since its last restart, clear counters operation, or + power-up. Then, this counter counts the number of broadcast + messages it has received. + + 0x10 6 Return Slave NAK Count + + Quantity of messages addressed to the remote device for which it + returned a Negative Acknowledge (NAK) exception response, since + its last restart, clear counters operation, or power-up. Exception + responses are described and listed in "MODBUS Application Protocol + Specification" document. + + 0x11 7 Return Slave Busy Count + + Quantity of messages addressed to the remote device for which it + returned a Slave Device Busy exception response, since its last + restart, clear counters operation, or power-up. Exception + responses are described and listed in "MODBUS Application + Protocol Specification" document. + + 0x12 8 Return Bus Character Overrun Count + + Quantity of messages addressed to the remote device that it could + not handle due to a character overrun condition, since its last + restart, clear counters operation, or power-up. A character + overrun is caused by data characters arriving at the port faster + than they can. + + .. note:: I threw the event counter in here for convinience + ''' + __data = dict([(i, 0x0000) for i in range(9)]) + __names = [ + 'BusMessage', + 'BusCommunicationError', + 'SlaveExceptionError', + 'SlaveMessage', + 'SlaveNoResponse', + 'SlaveNAK', + 'SlaveBusy', + 'BusCharacterOverrun' + 'Event ' + ] + + def __iter__(self): + ''' Iterater over the device counters + + :returns: An iterator of the device counters + ''' + return izip(self.__names, itervalues(self.__data)) + + def update(self, values): + ''' Update the values of this identity + using another identify as the value + + :param values: The value to copy values from + ''' + for k, v in iteritems(values): + v += self.__getattribute__(k) + self.__setattr__(k, v) + + def reset(self): + ''' This clears all of the system counters + ''' + self.__data = dict([(i, 0x0000) for i in range(9)]) + + def summary(self): + ''' Returns a summary of the counters current status + + :returns: A byte with each bit representing each counter + ''' + count, result = 0x01, 0x00 + for i in itervalues(self.__data): + if i != 0x00: result |= count + count <<= 1 + return result + + #-------------------------------------------------------------------------# + # Properties + #-------------------------------------------------------------------------# + BusMessage = dict_property(lambda s: s.__data, 0) + BusCommunicationError = dict_property(lambda s: s.__data, 1) + BusExceptionError = dict_property(lambda s: s.__data, 2) + SlaveMessage = dict_property(lambda s: s.__data, 3) + SlaveNoResponse = dict_property(lambda s: s.__data, 4) + SlaveNAK = dict_property(lambda s: s.__data, 5) + SlaveBusy = dict_property(lambda s: s.__data, 6) + BusCharacterOverrun = dict_property(lambda s: s.__data, 7) + Event = dict_property(lambda s: s.__data, 8) + + +#---------------------------------------------------------------------------# +# Main server control block +#---------------------------------------------------------------------------# +class ModbusControlBlock(Singleton): + ''' + This is a global singleton that controls all system information + + All activity should be logged here and all diagnostic requests + should come from here. + ''' + + __mode = 'ASCII' + __diagnostic = [False] * 16 + __instance = None + __listen_only = False + __delimiter = '\r' + __counters = ModbusCountersHandler() + __identity = ModbusDeviceIdentification() + __plus = ModbusPlusStatistics() + __events = [] + + #-------------------------------------------------------------------------# + # Magic + #-------------------------------------------------------------------------# + def __str__(self): + ''' Build a representation of the control block + + :returns: A string representation of the control block + ''' + return "ModbusControl" + + def __iter__(self): + ''' Iterater over the device counters + + :returns: An iterator of the device counters + ''' + return self.__counters.__iter__() + + #-------------------------------------------------------------------------# + # Events + #-------------------------------------------------------------------------# + def addEvent(self, event): + ''' Adds a new event to the event log + + :param event: A new event to add to the log + ''' + self.__events.insert(0, event) + self.__events = self.__events[0:64] # chomp to 64 entries + self.Counter.Event += 1 + + def getEvents(self): + ''' Returns an encoded collection of the event log. + + :returns: The encoded events packet + ''' + events = [event.encode() for event in self.__events] + return b''.join(events) + + def clearEvents(self): + ''' Clears the current list of events + ''' + self.__events = [] + + #-------------------------------------------------------------------------# + # Other Properties + #-------------------------------------------------------------------------# + Identity = property(lambda s: s.__identity) + Counter = property(lambda s: s.__counters) + Events = property(lambda s: s.__events) + Plus = property(lambda s: s.__plus) + + def reset(self): + ''' This clears all of the system counters and the + diagnostic register + ''' + self.__events = [] + self.__counters.reset() + self.__diagnostic = [False] * 16 + + #-------------------------------------------------------------------------# + # Listen Properties + #-------------------------------------------------------------------------# + def _setListenOnly(self, value): + ''' This toggles the listen only status + + :param value: The value to set the listen status to + ''' + self.__listen_only = bool(value) + + ListenOnly = property(lambda s: s.__listen_only, _setListenOnly) + + #-------------------------------------------------------------------------# + # Mode Properties + #-------------------------------------------------------------------------# + def _setMode(self, mode): + ''' This toggles the current serial mode + + :param mode: The data transfer method in (RTU, ASCII) + ''' + if mode in ['ASCII', 'RTU']: + self.__mode = mode + + Mode = property(lambda s: s.__mode, _setMode) + + #-------------------------------------------------------------------------# + # Delimiter Properties + #-------------------------------------------------------------------------# + def _setDelimiter(self, char): + ''' This changes the serial delimiter character + + :param char: The new serial delimiter character + ''' + if isinstance(char, str): + self.__delimiter = char.encode() + if isinstance(char, bytes): + self.__delimiter = char + elif isinstance(char, int): + self.__delimiter = int2byte(char) + + Delimiter = property(lambda s: s.__delimiter, _setDelimiter) + + #-------------------------------------------------------------------------# + # Diagnostic Properties + #-------------------------------------------------------------------------# + def setDiagnostic(self, mapping): + ''' This sets the value in the diagnostic register + + :param mapping: Dictionary of key:value pairs to set + ''' + for entry in iteritems(mapping): + if entry[0] >= 0 and entry[0] < len(self.__diagnostic): + self.__diagnostic[entry[0]] = (entry[1] != 0) + + def getDiagnostic(self, bit): + ''' This gets the value in the diagnostic register + + :param bit: The bit to get + :returns: The current value of the requested bit + ''' + try: + if bit and bit >= 0 and bit < len(self.__diagnostic): + return self.__diagnostic[bit] + except Exception: + return None + + def getDiagnosticRegister(self): + ''' This gets the entire diagnostic register + + :returns: The diagnostic register collection + ''' + return self.__diagnostic + +#---------------------------------------------------------------------------# +# Exported Identifiers +#---------------------------------------------------------------------------# +__all__ = [ + "ModbusAccessControl", + "ModbusPlusStatistics", + "ModbusDeviceIdentification", + "DeviceInformationFactory", + "ModbusControlBlock" +] diff --git a/modbus/pymodbus/diag_message.py b/modbus/pymodbus/diag_message.py new file mode 100644 index 00000000..b76782c3 --- /dev/null +++ b/modbus/pymodbus/diag_message.py @@ -0,0 +1,790 @@ +''' +Diagnostic Record Read/Write +------------------------------ + +These need to be tied into a the current server context +or linked to the appropriate data +''' +import struct + +from pymodbus.constants import ModbusStatus, ModbusPlusOperation +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.device import ModbusControlBlock +from pymodbus.exceptions import NotImplementedException +from pymodbus.utilities import pack_bitstring + +_MCB = ModbusControlBlock() + + +#---------------------------------------------------------------------------# +# Diagnostic Function Codes Base Classes +# diagnostic 08, 00-18,20 +#---------------------------------------------------------------------------# +# TODO Make sure all the data is decoded from the response +#---------------------------------------------------------------------------# +class DiagnosticStatusRequest(ModbusRequest): + ''' + This is a base class for all of the diagnostic request functions + ''' + function_code = 0x08 + _rtu_frame_size = 8 + + def __init__(self, **kwargs): + ''' + Base initializer for a diagnostic request + ''' + ModbusRequest.__init__(self, **kwargs) + self.message = None + + def encode(self): + ''' + Base encoder for a diagnostic response + we encode the data set in self.message + + :returns: The encoded packet + ''' + packet = struct.pack('>H', self.sub_function_code) + if self.message is not None: + if isinstance(self.message, str): + packet += self.message.encode() + elif isinstance(self.message, bytes): + packet += self.message + elif isinstance(self.message, list): + for piece in self.message: + packet += struct.pack('>H', piece) + elif isinstance(self.message, int): + packet += struct.pack('>H', self.message) + return packet + + def decode(self, data): + ''' Base decoder for a diagnostic request + + :param data: The data to decode into the function code + ''' + self.sub_function_code, self.message = struct.unpack('>HH', data) + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Sub function code (2 byte) + Data (2 * N bytes) + :return: + """ + if not isinstance(self.message,list): + self.message = [self.message] + return 1 + 2 + 2 * len(self.message) + + +class DiagnosticStatusResponse(ModbusResponse): + ''' + This is a base class for all of the diagnostic response functions + + It works by performing all of the encoding and decoding of variable + data and lets the higher classes define what extra data to append + and how to execute a request + ''' + function_code = 0x08 + _rtu_frame_size = 8 + + def __init__(self, **kwargs): + ''' + Base initializer for a diagnostic response + ''' + ModbusResponse.__init__(self, **kwargs) + self.message = None + + def encode(self): + ''' + Base encoder for a diagnostic response + we encode the data set in self.message + + :returns: The encoded packet + ''' + packet = struct.pack('>H', self.sub_function_code) + if self.message is not None: + if isinstance(self.message, str): + packet += self.message.encode() + elif isinstance(self.message, bytes): + packet += self.message + elif isinstance(self.message, list): + for piece in self.message: + packet += struct.pack('>H', piece) + elif isinstance(self.message, int): + packet += struct.pack('>H', self.message) + return packet + + def decode(self, data): + ''' Base decoder for a diagnostic response + + :param data: The data to decode into the function code + ''' + word_len = len(data)//2 + if len(data) % 2: + word_len += 1 + data = data + b'0' + data = struct.unpack('>' + 'H'*word_len, data) + self.sub_function_code, self.message = data[0], data[1:] + + +class DiagnosticStatusSimpleRequest(DiagnosticStatusRequest): + ''' + A large majority of the diagnostic functions are simple + status request functions. They work by sending 0x0000 + as data and their function code and they are returned + 2 bytes of data. + + If a function inherits this, they only need to implement + the execute method + ''' + + def __init__(self, data=0x0000, **kwargs): + ''' + General initializer for a simple diagnostic request + + The data defaults to 0x0000 if not provided as over half + of the functions require it. + + :param data: The data to send along with the request + ''' + DiagnosticStatusRequest.__init__(self, **kwargs) + self.message = data + + def execute(self, *args): + ''' Base function to raise if not implemented ''' + raise NotImplementedException("Diagnostic Message Has No Execute Method") + + +class DiagnosticStatusSimpleResponse(DiagnosticStatusResponse): + ''' + A large majority of the diagnostic functions are simple + status request functions. They work by sending 0x0000 + as data and their function code and they are returned + 2 bytes of data. + ''' + + def __init__(self, data=0x0000, **kwargs): + ''' General initializer for a simple diagnostic response + + :param data: The resulting data to return to the client + ''' + DiagnosticStatusResponse.__init__(self, **kwargs) + self.message = data + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 00 +#---------------------------------------------------------------------------# +class ReturnQueryDataRequest(DiagnosticStatusRequest): + ''' + The data passed in the request data field is to be returned (looped back) + in the response. The entire response message should be identical to the + request. + ''' + sub_function_code = 0x0000 + + def __init__(self, message=0x0000, **kwargs): + ''' Initializes a new instance of the request + + :param message: The message to send to loopback + ''' + DiagnosticStatusRequest.__init__(self, **kwargs) + if isinstance(message, list): + self.message = message + else: + self.message = [message] + + def execute(self, *args): + ''' Executes the loopback request (builds the response) + + :returns: The populated loopback response message + ''' + return ReturnQueryDataResponse(self.message) + + +class ReturnQueryDataResponse(DiagnosticStatusResponse): + ''' + The data passed in the request data field is to be returned (looped back) + in the response. The entire response message should be identical to the + request. + ''' + sub_function_code = 0x0000 + + def __init__(self, message=0x0000, **kwargs): + ''' Initializes a new instance of the response + + :param message: The message to loopback + ''' + DiagnosticStatusResponse.__init__(self, **kwargs) + if isinstance(message, list): + self.message = message + else: self.message = [message] + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 01 +#---------------------------------------------------------------------------# +class RestartCommunicationsOptionRequest(DiagnosticStatusRequest): + ''' + The remote device serial line port must be initialized and restarted, and + all of its communications event counters are cleared. If the port is + currently in Listen Only Mode, no response is returned. This function is + the only one that brings the port out of Listen Only Mode. If the port is + not currently in Listen Only Mode, a normal response is returned. This + occurs before the restart is executed. + ''' + sub_function_code = 0x0001 + + def __init__(self, toggle=False, **kwargs): + ''' Initializes a new request + + :param toggle: Set to True to toggle, False otherwise + ''' + DiagnosticStatusRequest.__init__(self, **kwargs) + if toggle: + self.message = [ModbusStatus.On] + else: self.message = [ModbusStatus.Off] + + def execute(self, *args): + ''' Clear event log and restart + + :returns: The initialized response message + ''' + #if _MCB.ListenOnly: + return RestartCommunicationsOptionResponse(self.message) + +class RestartCommunicationsOptionResponse(DiagnosticStatusResponse): + ''' + The remote device serial line port must be initialized and restarted, and + all of its communications event counters are cleared. If the port is + currently in Listen Only Mode, no response is returned. This function is + the only one that brings the port out of Listen Only Mode. If the port is + not currently in Listen Only Mode, a normal response is returned. This + occurs before the restart is executed. + ''' + sub_function_code = 0x0001 + + def __init__(self, toggle=False, **kwargs): + ''' Initializes a new response + + :param toggle: Set to True if we toggled, False otherwise + ''' + DiagnosticStatusResponse.__init__(self, **kwargs) + if toggle: + self.message = [ModbusStatus.On] + else: self.message = [ModbusStatus.Off] + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 02 +#---------------------------------------------------------------------------# +class ReturnDiagnosticRegisterRequest(DiagnosticStatusSimpleRequest): + ''' + The contents of the remote device's 16-bit diagnostic register are + returned in the response + ''' + sub_function_code = 0x0002 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + #if _MCB.isListenOnly(): + register = pack_bitstring(_MCB.getDiagnosticRegister()) + return ReturnDiagnosticRegisterResponse(register) + + +class ReturnDiagnosticRegisterResponse(DiagnosticStatusSimpleResponse): + ''' + The contents of the remote device's 16-bit diagnostic register are + returned in the response + ''' + sub_function_code = 0x0002 + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 03 +#---------------------------------------------------------------------------# +class ChangeAsciiInputDelimiterRequest(DiagnosticStatusSimpleRequest): + ''' + The character 'CHAR' passed in the request data field becomes the end of + message delimiter for future messages (replacing the default LF + character). This function is useful in cases of a Line Feed is not + required at the end of ASCII messages. + ''' + sub_function_code = 0x0003 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + char = (self.message & 0xff00) >> 8 + _MCB.Delimiter = char + return ChangeAsciiInputDelimiterResponse(self.message) + + +class ChangeAsciiInputDelimiterResponse(DiagnosticStatusSimpleResponse): + ''' + The character 'CHAR' passed in the request data field becomes the end of + message delimiter for future messages (replacing the default LF + character). This function is useful in cases of a Line Feed is not + required at the end of ASCII messages. + ''' + sub_function_code = 0x0003 + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 04 +#---------------------------------------------------------------------------# +class ForceListenOnlyModeRequest(DiagnosticStatusSimpleRequest): + ''' + Forces the addressed remote device to its Listen Only Mode for MODBUS + communications. This isolates it from the other devices on the network, + allowing them to continue communicating without interruption from the + addressed remote device. No response is returned. + ''' + sub_function_code = 0x0004 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + _MCB.ListenOnly = True + return ForceListenOnlyModeResponse() + + +class ForceListenOnlyModeResponse(DiagnosticStatusResponse): + ''' + Forces the addressed remote device to its Listen Only Mode for MODBUS + communications. This isolates it from the other devices on the network, + allowing them to continue communicating without interruption from the + addressed remote device. No response is returned. + + This does not send a response + ''' + sub_function_code = 0x0004 + should_respond = False + + def __init__(self, **kwargs): + ''' Initializer to block a return response + ''' + DiagnosticStatusResponse.__init__(self, **kwargs) + self.message = [] + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 10 +#---------------------------------------------------------------------------# +class ClearCountersRequest(DiagnosticStatusSimpleRequest): + ''' + The goal is to clear ll counters and the diagnostic register. + Also, counters are cleared upon power-up + ''' + sub_function_code = 0x000A + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + _MCB.reset() + return ClearCountersResponse(self.message) + + +class ClearCountersResponse(DiagnosticStatusSimpleResponse): + ''' + The goal is to clear ll counters and the diagnostic register. + Also, counters are cleared upon power-up + ''' + sub_function_code = 0x000A + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 11 +#---------------------------------------------------------------------------# +class ReturnBusMessageCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of messages that the + remote device has detected on the communications systems since its last + restart, clear counters operation, or power-up + ''' + sub_function_code = 0x000B + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.BusMessage + return ReturnBusMessageCountResponse(count) + + +class ReturnBusMessageCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of messages that the + remote device has detected on the communications systems since its last + restart, clear counters operation, or power-up + ''' + sub_function_code = 0x000B + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 12 +#---------------------------------------------------------------------------# +class ReturnBusCommunicationErrorCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of CRC errors encountered + by the remote device since its last restart, clear counter operation, or + power-up + ''' + sub_function_code = 0x000C + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.BusCommunicationError + return ReturnBusCommunicationErrorCountResponse(count) + + +class ReturnBusCommunicationErrorCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of CRC errors encountered + by the remote device since its last restart, clear counter operation, or + power-up + ''' + sub_function_code = 0x000C + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 13 +#---------------------------------------------------------------------------# +class ReturnBusExceptionErrorCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of modbus exception + responses returned by the remote device since its last restart, + clear counters operation, or power-up + ''' + sub_function_code = 0x000D + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.BusExceptionError + return ReturnBusExceptionErrorCountResponse(count) + + +class ReturnBusExceptionErrorCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of modbus exception + responses returned by the remote device since its last restart, + clear counters operation, or power-up + ''' + sub_function_code = 0x000D + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 14 +#---------------------------------------------------------------------------# +class ReturnSlaveMessageCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of messages addressed to the + remote device, or broadcast, that the remote device has processed since + its last restart, clear counters operation, or power-up + ''' + sub_function_code = 0x000E + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.SlaveMessage + return ReturnSlaveMessageCountResponse(count) + + +class ReturnSlaveMessageCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of messages addressed to the + remote device, or broadcast, that the remote device has processed since + its last restart, clear counters operation, or power-up + ''' + sub_function_code = 0x000E + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 15 +#---------------------------------------------------------------------------# +class ReturnSlaveNoResponseCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of messages addressed to the + remote device, or broadcast, that the remote device has processed since + its last restart, clear counters operation, or power-up + ''' + sub_function_code = 0x000F + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.SlaveNoResponse + return ReturnSlaveNoReponseCountResponse(count) + + +class ReturnSlaveNoReponseCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of messages addressed to the + remote device, or broadcast, that the remote device has processed since + its last restart, clear counters operation, or power-up + ''' + sub_function_code = 0x000F + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 16 +#---------------------------------------------------------------------------# +class ReturnSlaveNAKCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Negative Acknowledge (NAK) exception + response, since its last restart, clear counters operation, or power-up. + Exception responses are described and listed in section 7 . + ''' + sub_function_code = 0x0010 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.SlaveNAK + return ReturnSlaveNAKCountResponse(count) + + +class ReturnSlaveNAKCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Negative Acknowledge (NAK) exception + response, since its last restart, clear counters operation, or power-up. + Exception responses are described and listed in section 7. + ''' + sub_function_code = 0x0010 + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 17 +#---------------------------------------------------------------------------# +class ReturnSlaveBusyCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Slave Device Busy exception response, + since its last restart, clear counters operation, or power-up. + ''' + sub_function_code = 0x0011 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.SlaveBusy + return ReturnSlaveBusyCountResponse(count) + + +class ReturnSlaveBusyCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Slave Device Busy exception response, + since its last restart, clear counters operation, or power-up. + ''' + sub_function_code = 0x0011 + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 18 +#---------------------------------------------------------------------------# +class ReturnSlaveBusCharacterOverrunCountRequest(DiagnosticStatusSimpleRequest): + ''' + The response data field returns the quantity of messages addressed to the + remote device that it could not handle due to a character overrun condition, + since its last restart, clear counters operation, or power-up. A character + overrun is caused by data characters arriving at the port faster than they + can be stored, or by the loss of a character due to a hardware malfunction. + ''' + sub_function_code = 0x0012 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.BusCharacterOverrun + return ReturnSlaveBusCharacterOverrunCountResponse(count) + + +class ReturnSlaveBusCharacterOverrunCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of messages addressed to the + remote device that it could not handle due to a character overrun condition, + since its last restart, clear counters operation, or power-up. A character + overrun is caused by data characters arriving at the port faster than they + can be stored, or by the loss of a character due to a hardware malfunction. + ''' + sub_function_code = 0x0012 + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 19 +#---------------------------------------------------------------------------# +class ReturnIopOverrunCountRequest(DiagnosticStatusSimpleRequest): + ''' + An IOP overrun is caused by data characters arriving at the port + faster than they can be stored, or by the loss of a character due + to a hardware malfunction. This function is specific to the 884. + ''' + sub_function_code = 0x0013 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + count = _MCB.Counter.BusCharacterOverrun + return ReturnIopOverrunCountResponse(count) + + +class ReturnIopOverrunCountResponse(DiagnosticStatusSimpleResponse): + ''' + The response data field returns the quantity of messages + addressed to the slave that it could not handle due to an 884 + IOP overrun condition, since its last restart, clear counters + operation, or power-up. + ''' + sub_function_code = 0x0013 + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 20 +#---------------------------------------------------------------------------# +class ClearOverrunCountRequest(DiagnosticStatusSimpleRequest): + ''' + Clears the overrun error counter and reset the error flag + + An error flag should be cleared, but nothing else in the + specification mentions is, so it is ignored. + ''' + sub_function_code = 0x0014 + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + _MCB.Counter.BusCharacterOverrun = 0x0000 + return ClearOverrunCountResponse(self.message) + + +class ClearOverrunCountResponse(DiagnosticStatusSimpleResponse): + ''' + Clears the overrun error counter and reset the error flag + ''' + sub_function_code = 0x0014 + + +#---------------------------------------------------------------------------# +# Diagnostic Sub Code 21 +#---------------------------------------------------------------------------# +class GetClearModbusPlusRequest(DiagnosticStatusSimpleRequest): + ''' + In addition to the Function code (08) and Subfunction code + (00 15 hex) in the query, a two-byte Operation field is used + to specify either a 'Get Statistics' or a 'Clear Statistics' + operation. The two operations are exclusive - the 'Get' + operation cannot clear the statistics, and the 'Clear' + operation does not return statistics prior to clearing + them. Statistics are also cleared on power-up of the slave + device. + ''' + sub_function_code = 0x0015 + + def __init__(self, **kwargs): + super(GetClearModbusPlusRequest, self).__init__(**kwargs) + + def get_response_pdu_size(self): + """ + Returns a series of 54 16-bit words (108 bytes) in the data field of the response + (this function differs from the usual two-byte length of the data field). The data + contains the statistics for the Modbus Plus peer processor in the slave device. + Func_code (1 byte) + Sub function code (2 byte) + Operation (2 byte) + Data (108 bytes) + :return: + """ + if self.message == ModbusPlusOperation.GetStatistics: + data = 2 + 108 # byte count(2) + data (54*2) + else: + data = 0 + return 1 + 2 + 2 + 2+ data + + def execute(self, *args): + ''' Execute the diagnostic request on the given device + + :returns: The initialized response message + ''' + message = None # the clear operation does not return info + if self.message == ModbusPlusOperation.ClearStatistics: + _MCB.Plus.reset() + message = self.message + else: + message = [self.message] + message += _MCB.Plus.encode() + return GetClearModbusPlusResponse(message) + + def encode(self): + ''' + Base encoder for a diagnostic response + we encode the data set in self.message + + :returns: The encoded packet + ''' + packet = struct.pack('>H', self.sub_function_code) + packet += struct.pack('>H', self.message) + return packet + + +class GetClearModbusPlusResponse(DiagnosticStatusSimpleResponse): + ''' + Returns a series of 54 16-bit words (108 bytes) in the data field + of the response (this function differs from the usual two-byte + length of the data field). The data contains the statistics for + the Modbus Plus peer processor in the slave device. + ''' + sub_function_code = 0x0015 + + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "DiagnosticStatusRequest", "DiagnosticStatusResponse", + "ReturnQueryDataRequest", "ReturnQueryDataResponse", + "RestartCommunicationsOptionRequest", "RestartCommunicationsOptionResponse", + "ReturnDiagnosticRegisterRequest", "ReturnDiagnosticRegisterResponse", + "ChangeAsciiInputDelimiterRequest", "ChangeAsciiInputDelimiterResponse", + "ForceListenOnlyModeRequest", "ForceListenOnlyModeResponse", + "ClearCountersRequest", "ClearCountersResponse", + "ReturnBusMessageCountRequest", "ReturnBusMessageCountResponse", + "ReturnBusCommunicationErrorCountRequest", "ReturnBusCommunicationErrorCountResponse", + "ReturnBusExceptionErrorCountRequest", "ReturnBusExceptionErrorCountResponse", + "ReturnSlaveMessageCountRequest", "ReturnSlaveMessageCountResponse", + "ReturnSlaveNoResponseCountRequest", "ReturnSlaveNoReponseCountResponse", + "ReturnSlaveNAKCountRequest", "ReturnSlaveNAKCountResponse", + "ReturnSlaveBusyCountRequest", "ReturnSlaveBusyCountResponse", + "ReturnSlaveBusCharacterOverrunCountRequest", "ReturnSlaveBusCharacterOverrunCountResponse", + "ReturnIopOverrunCountRequest", "ReturnIopOverrunCountResponse", + "ClearOverrunCountRequest", "ClearOverrunCountResponse", + "GetClearModbusPlusRequest", "GetClearModbusPlusResponse", +] diff --git a/modbus/pymodbus/events.py b/modbus/pymodbus/events.py new file mode 100644 index 00000000..c3ed367f --- /dev/null +++ b/modbus/pymodbus/events.py @@ -0,0 +1,197 @@ +''' +Modbus Remote Events +------------------------------------------------------------ + +An event byte returned by the Get Communications Event Log function +can be any one of four types. The type is defined by bit 7 +(the high-order bit) in each byte. It may be further defined by bit 6. +''' +from pymodbus.exceptions import NotImplementedException +from pymodbus.exceptions import ParameterException +from pymodbus.utilities import pack_bitstring, unpack_bitstring + + +class ModbusEvent(object): + + def encode(self): + ''' Encodes the status bits to an event message + + :returns: The encoded event message + ''' + raise NotImplementedException() + + def decode(self, event): + ''' Decodes the event message to its status bits + + :param event: The event to decode + ''' + raise NotImplementedException() + + +class RemoteReceiveEvent(ModbusEvent): + ''' Remote device MODBUS Receive Event + + The remote device stores this type of event byte when a query message + is received. It is stored before the remote device processes the message. + This event is defined by bit 7 set to logic '1'. The other bits will be + set to a logic '1' if the corresponding condition is TRUE. The bit layout + is:: + + Bit Contents + ---------------------------------- + 0 Not Used + 2 Not Used + 3 Not Used + 4 Character Overrun + 5 Currently in Listen Only Mode + 6 Broadcast Receive + 7 1 + ''' + + def __init__(self, **kwargs): + ''' Initialize a new event instance + ''' + self.overrun = kwargs.get('overrun', False) + self.listen = kwargs.get('listen', False) + self.broadcast = kwargs.get('broadcast', False) + + def encode(self): + ''' Encodes the status bits to an event message + + :returns: The encoded event message + ''' + bits = [False] * 3 + bits += [self.overrun, self.listen, self.broadcast, True] + packet = pack_bitstring(bits) + return packet + + def decode(self, event): + ''' Decodes the event message to its status bits + + :param event: The event to decode + ''' + bits = unpack_bitstring(event) + self.overrun = bits[4] + self.listen = bits[5] + self.broadcast = bits[6] + + +class RemoteSendEvent(ModbusEvent): + ''' Remote device MODBUS Send Event + + The remote device stores this type of event byte when it finishes + processing a request message. It is stored if the remote device + returned a normal or exception response, or no response. + + This event is defined by bit 7 set to a logic '0', with bit 6 set to a '1'. + The other bits will be set to a logic '1' if the corresponding + condition is TRUE. The bit layout is:: + + Bit Contents + ----------------------------------------------------------- + 0 Read Exception Sent (Exception Codes 1-3) + 1 Slave Abort Exception Sent (Exception Code 4) + 2 Slave Busy Exception Sent (Exception Codes 5-6) + 3 Slave Program NAK Exception Sent (Exception Code 7) + 4 Write Timeout Error Occurred + 5 Currently in Listen Only Mode + 6 1 + 7 0 + ''' + + def __init__(self, **kwargs): + ''' Initialize a new event instance + ''' + self.read = kwargs.get('read', False) + self.slave_abort = kwargs.get('slave_abort', False) + self.slave_busy = kwargs.get('slave_busy', False) + self.slave_nak = kwargs.get('slave_nak', False) + self.write_timeout = kwargs.get('write_timeout', False) + self.listen = kwargs.get('listen', False) + + def encode(self): + ''' Encodes the status bits to an event message + + :returns: The encoded event message + ''' + bits = [self.read, self.slave_abort, self.slave_busy, + self.slave_nak, self.write_timeout, self.listen] + bits += [True, False] + packet = pack_bitstring(bits) + return packet + + def decode(self, event): + ''' Decodes the event message to its status bits + + :param event: The event to decode + ''' + # todo fix the start byte count + bits = unpack_bitstring(event) + self.read = bits[0] + self.slave_abort = bits[1] + self.slave_busy = bits[2] + self.slave_nak = bits[3] + self.write_timeout = bits[4] + self.listen = bits[5] + + +class EnteredListenModeEvent(ModbusEvent): + ''' Remote device Entered Listen Only Mode + + The remote device stores this type of event byte when it enters + the Listen Only Mode. The event is defined by a content of 04 hex. + ''' + + value = 0x04 + __encoded = b'\x04' + + def encode(self): + ''' Encodes the status bits to an event message + + :returns: The encoded event message + ''' + return self.__encoded + + def decode(self, event): + ''' Decodes the event message to its status bits + + :param event: The event to decode + ''' + if event != self.__encoded: + raise ParameterException('Invalid decoded value') + + +class CommunicationRestartEvent(ModbusEvent): + ''' Remote device Initiated Communication Restart + + The remote device stores this type of event byte when its communications + port is restarted. The remote device can be restarted by the Diagnostics + function (code 08), with sub-function Restart Communications Option + (code 00 01). + + That function also places the remote device into a 'Continue on Error' + or 'Stop on Error' mode. If the remote device is placed into 'Continue on + Error' mode, the event byte is added to the existing event log. If the + remote device is placed into 'Stop on Error' mode, the byte is added to + the log and the rest of the log is cleared to zeros. + + The event is defined by a content of zero. + ''' + + value = 0x00 + __encoded = b'\x00' + + def encode(self): + ''' Encodes the status bits to an event message + + :returns: The encoded event message + ''' + return self.__encoded + + def decode(self, event): + ''' Decodes the event message to its status bits + + :param event: The event to decode + ''' + if event != self.__encoded: + raise ParameterException('Invalid decoded value') diff --git a/modbus/pymodbus/exceptions.py b/modbus/pymodbus/exceptions.py new file mode 100644 index 00000000..0a4b0f6d --- /dev/null +++ b/modbus/pymodbus/exceptions.py @@ -0,0 +1,130 @@ +""" +Pymodbus Exceptions +-------------------- + +Custom exceptions to be used in the Modbus code. +""" + + +class ModbusException(Exception): + """ Base modbus exception """ + + def __init__(self, string): + """ Initialize the exception + :param string: The message to append to the error + """ + self.string = string + + def __str__(self): + return 'Modbus Error: %s' % self.string + + def isError(self): + """Error""" + return True + + +class ModbusIOException(ModbusException): + """ Error resulting from data i/o """ + + def __init__(self, string="", function_code=None): + """ Initialize the exception + :param string: The message to append to the error + """ + self.fcode = function_code + self.message = "[Input/Output] %s" % string + ModbusException.__init__(self, self.message) + + +class ParameterException(ModbusException): + """ Error resulting from invalid parameter """ + + def __init__(self, string=""): + """ Initialize the exception + + :param string: The message to append to the error + """ + message = "[Invalid Parameter] %s" % string + ModbusException.__init__(self, message) + + +class NoSuchSlaveException(ModbusException): + """ Error resulting from making a request to a slave + that does not exist """ + + def __init__(self, string=""): + """ Initialize the exception + + :param string: The message to append to the error + """ + message = "[No Such Slave] %s" % string + ModbusException.__init__(self, message) + + +class NotImplementedException(ModbusException): + """ Error resulting from not implemented function """ + + def __init__(self, string=""): + """ Initialize the exception + :param string: The message to append to the error + """ + message = "[Not Implemented] %s" % string + ModbusException.__init__(self, message) + + +class ConnectionException(ModbusException): + """ Error resulting from a bad connection """ + + def __init__(self, string=""): + """ Initialize the exception + + :param string: The message to append to the error + """ + message = "[Connection] %s" % string + ModbusException.__init__(self, message) + + +class InvalidMessageReceivedException(ModbusException): + """ + Error resulting from invalid response received or decoded + """ + + def __init__(self, string=""): + """ Initialize the exception + + :param string: The message to append to the error + """ + message = "[Invalid Message] %s" % string + ModbusException.__init__(self, message) + + +class MessageRegisterException(ModbusException): + """ + Error resulting from failing to register a custom message request/response + """ + def __init__(self, string=""): + message = '[Error registering message] %s' % string + ModbusException.__init__(self, message) + + +class TimeOutException(ModbusException): + """ Error resulting from modbus response timeout """ + + def __init__(self, string=""): + """ Initialize the exception + + :param string: The message to append to the error + """ + message = "[Timeout] %s" % string + ModbusException.__init__(self, message) + + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # +__all__ = [ + "ModbusException", "ModbusIOException", + "ParameterException", "NotImplementedException", + "ConnectionException", "NoSuchSlaveException", + "InvalidMessageReceivedException", + "MessageRegisterException", "TimeOutException" +] diff --git a/modbus/pymodbus/factory.py b/modbus/pymodbus/factory.py new file mode 100644 index 00000000..ad2dff1a --- /dev/null +++ b/modbus/pymodbus/factory.py @@ -0,0 +1,312 @@ +""" +Modbus Request/Response Decoder Factories +------------------------------------------- + +The following factories make it easy to decode request/response messages. +To add a new request/response pair to be decodeable by the library, simply +add them to the respective function lookup table (order doesn't matter, but +it does help keep things organized). + +Regardless of how many functions are added to the lookup, O(1) behavior is +kept as a result of a pre-computed lookup dictionary. +""" + +from pymodbus.pdu import IllegalFunctionRequest +from pymodbus.pdu import ExceptionResponse +from pymodbus.pdu import ModbusRequest, ModbusResponse +from pymodbus.pdu import ModbusExceptions as ecode +from pymodbus.interfaces import IModbusDecoder +from pymodbus.exceptions import ModbusException, MessageRegisterException +from pymodbus.bit_read_message import * +from pymodbus.bit_write_message import * +from pymodbus.diag_message import * +from pymodbus.file_message import * +from pymodbus.other_message import * +from pymodbus.mei_message import * +from pymodbus.register_read_message import * +from pymodbus.register_write_message import * +from pymodbus.compat import byte2int + + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Server Decoder +# --------------------------------------------------------------------------- # +class ServerDecoder(IModbusDecoder): + """ Request Message Factory (Server) + + To add more implemented functions, simply add them to the list + """ + __function_table = [ + ReadHoldingRegistersRequest, + ReadDiscreteInputsRequest, + ReadInputRegistersRequest, + ReadCoilsRequest, + WriteMultipleCoilsRequest, + WriteMultipleRegistersRequest, + WriteSingleRegisterRequest, + WriteSingleCoilRequest, + ReadWriteMultipleRegistersRequest, + DiagnosticStatusRequest, + ReadExceptionStatusRequest, + GetCommEventCounterRequest, + GetCommEventLogRequest, + ReportSlaveIdRequest, + ReadFileRecordRequest, + WriteFileRecordRequest, + MaskWriteRegisterRequest, + ReadFifoQueueRequest, + ReadDeviceInformationRequest, + ] + __sub_function_table = [ + ReturnQueryDataRequest, + RestartCommunicationsOptionRequest, + ReturnDiagnosticRegisterRequest, + ChangeAsciiInputDelimiterRequest, + ForceListenOnlyModeRequest, + ClearCountersRequest, + ReturnBusMessageCountRequest, + ReturnBusCommunicationErrorCountRequest, + ReturnBusExceptionErrorCountRequest, + ReturnSlaveMessageCountRequest, + ReturnSlaveNoResponseCountRequest, + ReturnSlaveNAKCountRequest, + ReturnSlaveBusyCountRequest, + ReturnSlaveBusCharacterOverrunCountRequest, + ReturnIopOverrunCountRequest, + ClearOverrunCountRequest, + GetClearModbusPlusRequest, + ReadDeviceInformationRequest, + ] + + def __init__(self): + """ Initializes the client lookup tables + """ + functions = set(f.function_code for f in self.__function_table) + self.__lookup = dict([(f.function_code, f) for f in self.__function_table]) + self.__sub_lookup = dict((f, {}) for f in functions) + for f in self.__sub_function_table: + self.__sub_lookup[f.function_code][f.sub_function_code] = f + + def decode(self, message): + """ Wrapper to decode a request packet + + :param message: The raw modbus request packet + :return: The decoded modbus message or None if error + """ + try: + return self._helper(message) + except ModbusException as er: + _logger.warning("Unable to decode request %s" % er) + return None + + def lookupPduClass(self, function_code): + """ Use `function_code` to determine the class of the PDU. + + :param function_code: The function code specified in a frame. + :returns: The class of the PDU that has a matching `function_code`. + """ + return self.__lookup.get(function_code, ExceptionResponse) + + def _helper(self, data): + """ + This factory is used to generate the correct request object + from a valid request packet. This decodes from a list of the + currently implemented request types. + + :param data: The request packet to decode + :returns: The decoded request or illegal function request object + """ + function_code = byte2int(data[0]) + request = self.__lookup.get(function_code, lambda: None)() + if not request: + _logger.debug("Factory Request[%d]" % function_code) + request = IllegalFunctionRequest(function_code) + else: + fc_string = "%s: %s" % ( + str(self.__lookup[function_code]).split('.')[-1].rstrip( + "'>"), + function_code + ) + _logger.debug("Factory Request[%s]" % fc_string) + request.decode(data[1:]) + + if hasattr(request, 'sub_function_code'): + lookup = self.__sub_lookup.get(request.function_code, {}) + subtype = lookup.get(request.sub_function_code, None) + if subtype: request.__class__ = subtype + + return request + + def register(self, function=None): + """ + Registers a function and sub function class with the decoder + :param function: Custom function class to register + :return: + """ + if function and not issubclass(function, ModbusRequest): + raise MessageRegisterException("'{}' is Not a valid Modbus Message" + ". Class needs to be derived from " + "`pymodbus.pdu.ModbusRequest` " + "".format( + function.__class__.__name__ + )) + self.__lookup[function.function_code] = function + if hasattr(function, "sub_function_code"): + if function.function_code not in self.__sub_lookup: + self.__sub_lookup[function.function_code] = dict() + self.__sub_lookup[function.function_code][ + function.sub_function_code] = function + + +# --------------------------------------------------------------------------- # +# Client Decoder +# --------------------------------------------------------------------------- # +class ClientDecoder(IModbusDecoder): + """ Response Message Factory (Client) + + To add more implemented functions, simply add them to the list + """ + __function_table = [ + ReadHoldingRegistersResponse, + ReadDiscreteInputsResponse, + ReadInputRegistersResponse, + ReadCoilsResponse, + WriteMultipleCoilsResponse, + WriteMultipleRegistersResponse, + WriteSingleRegisterResponse, + WriteSingleCoilResponse, + ReadWriteMultipleRegistersResponse, + DiagnosticStatusResponse, + ReadExceptionStatusResponse, + GetCommEventCounterResponse, + GetCommEventLogResponse, + ReportSlaveIdResponse, + ReadFileRecordResponse, + WriteFileRecordResponse, + MaskWriteRegisterResponse, + ReadFifoQueueResponse, + ReadDeviceInformationResponse, + ] + __sub_function_table = [ + ReturnQueryDataResponse, + RestartCommunicationsOptionResponse, + ReturnDiagnosticRegisterResponse, + ChangeAsciiInputDelimiterResponse, + ForceListenOnlyModeResponse, + ClearCountersResponse, + ReturnBusMessageCountResponse, + ReturnBusCommunicationErrorCountResponse, + ReturnBusExceptionErrorCountResponse, + ReturnSlaveMessageCountResponse, + ReturnSlaveNoReponseCountResponse, + ReturnSlaveNAKCountResponse, + ReturnSlaveBusyCountResponse, + ReturnSlaveBusCharacterOverrunCountResponse, + ReturnIopOverrunCountResponse, + ClearOverrunCountResponse, + GetClearModbusPlusResponse, + ReadDeviceInformationResponse, + ] + + def __init__(self): + """ Initializes the client lookup tables + """ + functions = set(f.function_code for f in self.__function_table) + self.__lookup = dict([(f.function_code, f) + for f in self.__function_table]) + self.__sub_lookup = dict((f, {}) for f in functions) + for f in self.__sub_function_table: + self.__sub_lookup[f.function_code][f.sub_function_code] = f + + def lookupPduClass(self, function_code): + """ Use `function_code` to determine the class of the PDU. + + :param function_code: The function code specified in a frame. + :returns: The class of the PDU that has a matching `function_code`. + """ + return self.__lookup.get(function_code, ExceptionResponse) + + def decode(self, message): + """ Wrapper to decode a response packet + + :param message: The raw packet to decode + :return: The decoded modbus message or None if error + """ + try: + return self._helper(message) + except ModbusException as er: + _logger.error("Unable to decode response %s" % er) + + except Exception as ex: + _logger.error(ex) + return None + + def _helper(self, data): + """ + This factory is used to generate the correct response object + from a valid response packet. This decodes from a list of the + currently implemented request types. + + :param data: The response packet to decode + :returns: The decoded request or an exception response object + """ + fc_string = function_code = byte2int(data[0]) + if function_code in self.__lookup: + fc_string = "%s: %s" % ( + str(self.__lookup[function_code]).split('.')[-1].rstrip("'>"), + function_code + ) + _logger.debug("Factory Response[%s]" % fc_string) + response = self.__lookup.get(function_code, lambda: None)() + if function_code > 0x80: + code = function_code & 0x7f # strip error portion + response = ExceptionResponse(code, ecode.IllegalFunction) + if not response: + raise ModbusException("Unknown response %d" % function_code) + response.decode(data[1:]) + + if hasattr(response, 'sub_function_code'): + lookup = self.__sub_lookup.get(response.function_code, {}) + subtype = lookup.get(response.sub_function_code, None) + if subtype: response.__class__ = subtype + + return response + + def register(self, function=None, sub_function=None, force=False): + """ + Registers a function and sub function class with the decoder + :param function: Custom function class to register + :param sub_function: Custom sub function class to register + :param force: Force update the existing class + :return: + """ + if function and not issubclass(function, ModbusResponse): + raise MessageRegisterException("'{}' is Not a valid Modbus Message" + ". Class needs to be derived from " + "`pymodbus.pdu.ModbusResponse` " + "".format( + function.__class__.__name__ + )) + self.__lookup[function.function_code] = function + if hasattr(function, "sub_function_code"): + if function.function_code not in self.__sub_lookup: + self.__sub_lookup[function.function_code] = dict() + self.__sub_lookup[function.function_code][ + function.sub_function_code] = function + + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + + +__all__ = ['ServerDecoder', 'ClientDecoder'] + + diff --git a/modbus/pymodbus/file_message.py b/modbus/pymodbus/file_message.py new file mode 100644 index 00000000..15fba111 --- /dev/null +++ b/modbus/pymodbus/file_message.py @@ -0,0 +1,395 @@ +''' +File Record Read/Write Messages +------------------------------- + +Currently none of these messages are implemented +''' +import struct +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.compat import byte2int + + +#---------------------------------------------------------------------------# +# File Record Types +#---------------------------------------------------------------------------# +class FileRecord(object): + ''' Represents a file record and its relevant data. + ''' + + def __init__(self, **kwargs): + ''' Initializes a new instance + + :params reference_type: Defaults to 0x06 (must be) + :params file_number: Indicates which file number we are reading + :params record_number: Indicates which record in the file + :params record_data: The actual data of the record + :params record_length: The length in registers of the record + :params response_length: The length in bytes of the record + ''' + self.reference_type = kwargs.get('reference_type', 0x06) + self.file_number = kwargs.get('file_number', 0x00) + self.record_number = kwargs.get('record_number', 0x00) + self.record_data = kwargs.get('record_data', '') + + self.record_length = kwargs.get('record_length', len(self.record_data) // 2) + self.response_length = kwargs.get('response_length', len(self.record_data) + 1) + + def __eq__(self, relf): + ''' Compares the left object to the right + ''' + return self.reference_type == relf.reference_type \ + and self.file_number == relf.file_number \ + and self.record_number == relf.record_number \ + and self.record_length == relf.record_length \ + and self.record_data == relf.record_data + + def __ne__(self, relf): + ''' Compares the left object to the right + ''' + return not self.__eq__(relf) + + def __repr__(self): + ''' Gives a representation of the file record + ''' + params = (self.file_number, self.record_number, self.record_length) + return 'FileRecord(file=%d, record=%d, length=%d)' % params + + +#---------------------------------------------------------------------------# +# File Requests/Responses +#---------------------------------------------------------------------------# +class ReadFileRecordRequest(ModbusRequest): + ''' + This function code is used to perform a file record read. All request + data lengths are provided in terms of number of bytes and all record + lengths are provided in terms of registers. + + A file is an organization of records. Each file contains 10000 records, + addressed 0000 to 9999 decimal or 0x0000 to 0x270f. For example, record + 12 is addressed as 12. The function can read multiple groups of + references. The groups can be separating (non-contiguous), but the + references within each group must be sequential. Each group is defined + in a seperate 'sub-request' field that contains seven bytes:: + + The reference type: 1 byte (must be 0x06) + The file number: 2 bytes + The starting record number within the file: 2 bytes + The length of the record to be read: 2 bytes + + The quantity of registers to be read, combined with all other fields + in the expected response, must not exceed the allowable length of the + MODBUS PDU: 235 bytes. + ''' + function_code = 0x14 + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, **kwargs): + ''' Initializes a new instance + + :param records: The file record requests to be read + ''' + ModbusRequest.__init__(self, **kwargs) + self.records = records or [] + + def encode(self): + ''' Encodes the request packet + + :returns: The byte encoded packet + ''' + packet = struct.pack('B', len(self.records) * 7) + for record in self.records: + packet += struct.pack('>BHHH', 0x06, record.file_number, + record.record_number, record.record_length) + return packet + + def decode(self, data): + ''' Decodes the incoming request + + :param data: The data to decode into the address + ''' + self.records = [] + byte_count = byte2int(data[0]) + for count in range(1, byte_count, 7): + decoded = struct.unpack('>BHHH', data[count:count+7]) + record = FileRecord(file_number=decoded[1], + record_number=decoded[2], record_length=decoded[3]) + if decoded[0] == 0x06: self.records.append(record) + + def execute(self, context): + ''' Run a read exeception status request against the store + + :param context: The datastore to request from + :returns: The populated response + ''' + # TODO do some new context operation here + # if file number, record number, or address + length + # is too big, return an error. + files = [] + return ReadFileRecordResponse(files) + + +class ReadFileRecordResponse(ModbusResponse): + ''' + The normal response is a series of 'sub-responses,' one for each + 'sub-request.' The byte count field is the total combined count of + bytes in all 'sub-responses.' In addition, each 'sub-response' + contains a field that shows its own byte count. + ''' + function_code = 0x14 + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, **kwargs): + ''' Initializes a new instance + + :param records: The requested file records + ''' + ModbusResponse.__init__(self, **kwargs) + self.records = records or [] + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + total = sum(record.response_length + 1 for record in self.records) + packet = struct.pack('B', total) + for record in self.records: + packet += struct.pack('>BB', 0x06, record.record_length) + packet += record.record_data + return packet + + def decode(self, data): + ''' Decodes a the response + + :param data: The packet data to decode + ''' + count, self.records = 1, [] + byte_count = byte2int(data[0]) + while count < byte_count: + response_length, reference_type = struct.unpack('>BB', data[count:count+2]) + count += response_length + 1 # the count is not included + record = FileRecord(response_length=response_length, + record_data=data[count - response_length + 1:count]) + if reference_type == 0x06: self.records.append(record) + + +class WriteFileRecordRequest(ModbusRequest): + ''' + This function code is used to perform a file record write. All + request data lengths are provided in terms of number of bytes + and all record lengths are provided in terms of the number of 16 + bit words. + ''' + function_code = 0x15 + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, **kwargs): + ''' Initializes a new instance + + :param records: The file record requests to be read + ''' + ModbusRequest.__init__(self, **kwargs) + self.records = records or [] + + def encode(self): + ''' Encodes the request packet + + :returns: The byte encoded packet + ''' + total_length = sum((record.record_length * 2) + 7 for record in self.records) + packet = struct.pack('B', total_length) + for record in self.records: + packet += struct.pack('>BHHH', 0x06, record.file_number, + record.record_number, record.record_length) + packet += record.record_data + return packet + + def decode(self, data): + ''' Decodes the incoming request + + :param data: The data to decode into the address + ''' + count, self.records = 1, [] + byte_count = byte2int(data[0]) + while count < byte_count: + decoded = struct.unpack('>BHHH', data[count:count+7]) + response_length = decoded[3] * 2 + count += response_length + 7 + record = FileRecord(record_length=decoded[3], + file_number=decoded[1], record_number=decoded[2], + record_data=data[count - response_length:count]) + if decoded[0] == 0x06: self.records.append(record) + + def execute(self, context): + ''' Run the write file record request against the context + + :param context: The datastore to request from + :returns: The populated response + ''' + # TODO do some new context operation here + # if file number, record number, or address + length + # is too big, return an error. + return WriteFileRecordResponse(self.records) + + +class WriteFileRecordResponse(ModbusResponse): + ''' + The normal response is an echo of the request. + ''' + function_code = 0x15 + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, **kwargs): + ''' Initializes a new instance + + :param records: The file record requests to be read + ''' + ModbusResponse.__init__(self, **kwargs) + self.records = records or [] + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + total_length = sum((record.record_length * 2) + 7 for record in self.records) + packet = struct.pack('B', total_length) + for record in self.records: + packet += struct.pack('>BHHH', 0x06, record.file_number, + record.record_number, record.record_length) + packet += record.record_data + return packet + + def decode(self, data): + ''' Decodes the incoming request + + :param data: The data to decode into the address + ''' + count, self.records = 1, [] + byte_count = byte2int(data[0]) + while count < byte_count: + decoded = struct.unpack('>BHHH', data[count:count+7]) + response_length = decoded[3] * 2 + count += response_length + 7 + record = FileRecord(record_length=decoded[3], + file_number=decoded[1], record_number=decoded[2], + record_data=data[count - response_length:count]) + if decoded[0] == 0x06: self.records.append(record) + + +class ReadFifoQueueRequest(ModbusRequest): + ''' + This function code allows to read the contents of a First-In-First-Out + (FIFO) queue of register in a remote device. The function returns a + count of the registers in the queue, followed by the queued data. + Up to 32 registers can be read: the count, plus up to 31 queued data + registers. + + The queue count register is returned first, followed by the queued data + registers. The function reads the queue contents, but does not clear + them. + ''' + function_code = 0x18 + _rtu_frame_size = 6 + + def __init__(self, address=0x0000, **kwargs): + ''' Initializes a new instance + + :param address: The fifo pointer address (0x0000 to 0xffff) + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + self.values = [] # this should be added to the context + + def encode(self): + ''' Encodes the request packet + + :returns: The byte encoded packet + ''' + return struct.pack('>H', self.address) + + def decode(self, data): + ''' Decodes the incoming request + + :param data: The data to decode into the address + ''' + self.address = struct.unpack('>H', data)[0] + + def execute(self, context): + ''' Run a read exeception status request against the store + + :param context: The datastore to request from + :returns: The populated response + ''' + if not (0x0000 <= self.address <= 0xffff): + return self.doException(merror.IllegalValue) + if len(self.values) > 31: + return self.doException(merror.IllegalValue) + # TODO pull the values from some context + return ReadFifoQueueResponse(self.values) + + +class ReadFifoQueueResponse(ModbusResponse): + ''' + In a normal response, the byte count shows the quantity of bytes to + follow, including the queue count bytes and value register bytes + (but not including the error check field). The queue count is the + quantity of data registers in the queue (not including the count register). + + If the queue count exceeds 31, an exception response is returned with an + error code of 03 (Illegal Data Value). + ''' + function_code = 0x18 + + @classmethod + def calculateRtuFrameSize(cls, buffer): + ''' Calculates the size of the message + + :param buffer: A buffer containing the data that have been received. + :returns: The number of bytes in the response. + ''' + hi_byte = byte2int(buffer[2]) + lo_byte = byte2int(buffer[3]) + return (hi_byte << 16) + lo_byte + 6 + + def __init__(self, values=None, **kwargs): + ''' Initializes a new instance + + :param values: The list of values of the fifo to return + ''' + ModbusResponse.__init__(self, **kwargs) + self.values = values or [] + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + length = len(self.values) * 2 + packet = struct.pack('>HH', 2 + length, length) + for value in self.values: + packet += struct.pack('>H', value) + return packet + + def decode(self, data): + ''' Decodes a the response + + :param data: The packet data to decode + ''' + self.values = [] + _, count = struct.unpack('>HH', data[0:4]) + for index in range(0, count - 4): + idx = 4 + index * 2 + self.values.append(struct.unpack('>H', data[idx:idx + 2])[0]) + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "FileRecord", + "ReadFileRecordRequest", "ReadFileRecordResponse", + "WriteFileRecordRequest", "WriteFileRecordResponse", + "ReadFifoQueueRequest", "ReadFifoQueueResponse", +] diff --git a/modbus/pymodbus/framer/__init__.py b/modbus/pymodbus/framer/__init__.py new file mode 100644 index 00000000..4859d010 --- /dev/null +++ b/modbus/pymodbus/framer/__init__.py @@ -0,0 +1,49 @@ +from pymodbus.interfaces import IModbusFramer +import struct + +# Unit ID, Function Code +BYTE_ORDER = '>' +FRAME_HEADER = 'BB' + +# Transaction Id, Protocol ID, Length, Unit ID, Function Code +SOCKET_FRAME_HEADER = BYTE_ORDER + 'HHH' + FRAME_HEADER + +# Function Code +TLS_FRAME_HEADER = BYTE_ORDER + 'B' + +class ModbusFramer(IModbusFramer): + """ + Base Framer class + """ + + def _validate_unit_id(self, units, single): + """ + Validates if the received data is valid for the client + :param units: list of unit id for which the transaction is valid + :param single: Set to true to treat this as a single context + :return: """ + + if single: + return True + else: + if 0 in units or 0xFF in units: + # Handle Modbus TCP unit identifier (0x00 0r 0xFF) + # in asynchronous requests + return True + return self._header['uid'] in units + + def sendPacket(self, message): + """ + Sends packets on the bus with 3.5char delay between frames + :param message: Message to be sent over the bus + :return: + """ + return self.client.send(message) + + def recvPacket(self, size): + """ + Receives packet from the bus with specified len + :param size: Number of bytes to read + :return: + """ + return self.client.recv(size) diff --git a/modbus/pymodbus/framer/ascii_framer.py b/modbus/pymodbus/framer/ascii_framer.py new file mode 100644 index 00000000..a88f6499 --- /dev/null +++ b/modbus/pymodbus/framer/ascii_framer.py @@ -0,0 +1,208 @@ +import struct +from binascii import b2a_hex, a2b_hex + +from pymodbus.exceptions import ModbusIOException +from pymodbus.utilities import checkLRC, computeLRC +from pymodbus.framer import ModbusFramer, FRAME_HEADER, BYTE_ORDER + + +ASCII_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Modbus ASCII Message +# --------------------------------------------------------------------------- # +class ModbusAsciiFramer(ModbusFramer): + """ + Modbus ASCII Frame Controller:: + + [ Start ][Address ][ Function ][ Data ][ LRC ][ End ] + 1c 2c 2c Nc 2c 2c + + * data can be 0 - 2x252 chars + * end is '\\r\\n' (Carriage return line feed), however the line feed + character can be changed via a special command + * start is ':' + + This framer is used for serial transmission. Unlike the RTU protocol, + the data in this framer is transferred in plain text ascii. + """ + + def __init__(self, decoder, client=None): + """ Initializes a new instance of the framer + + :param decoder: The decoder implementation to use + """ + self._buffer = b'' + self._header = {'lrc': '0000', 'len': 0, 'uid': 0x00} + self._hsize = 0x02 + self._start = b':' + self._end = b"\r\n" + self.decoder = decoder + self.client = client + + # ----------------------------------------------------------------------- # + # Private Helper Functions + # ----------------------------------------------------------------------- # + def decode_data(self, data): + if len(data) > 1: + uid = int(data[1:3], 16) + fcode = int(data[3:5], 16) + return dict(unit=uid, fcode=fcode) + return dict() + + def checkFrame(self): + """ Check and decode the next frame + + :returns: True if we successful, False otherwise + """ + start = self._buffer.find(self._start) + if start == -1: + return False + if start > 0: # go ahead and skip old bad data + self._buffer = self._buffer[start:] + start = 0 + + end = self._buffer.find(self._end) + if end != -1: + self._header['len'] = end + self._header['uid'] = int(self._buffer[1:3], 16) + self._header['lrc'] = int(self._buffer[end - 2:end], 16) + data = a2b_hex(self._buffer[start + 1:end - 2]) + return checkLRC(data, self._header['lrc']) + return False + + def advanceFrame(self): + """ Skip over the current framed message + This allows us to skip over the current message after we have processed + it or determined that it contains an error. It also has to reset the + current frame header handle + """ + self._buffer = self._buffer[self._header['len'] + 2:] + self._header = {'lrc': '0000', 'len': 0, 'uid': 0x00} + + def isFrameReady(self): + """ Check if we should continue decode logic + This is meant to be used in a while loop in the decoding phase to let + the decoder know that there is still data in the buffer. + + :returns: True if ready, False otherwise + """ + return len(self._buffer) > 1 + + def addToFrame(self, message): + """ Add the next message to the frame buffer + This should be used before the decoding while loop to add the received + data to the buffer handle. + + :param message: The most recent packet + """ + self._buffer += message + + def getFrame(self): + """ Get the next frame from the buffer + + :returns: The frame data or '' + """ + start = self._hsize + 1 + end = self._header['len'] - 2 + buffer = self._buffer[start:end] + if end > 0: + return a2b_hex(buffer) + return b'' + + def resetFrame(self): + """ Reset the entire message frame. + This allows us to skip ovver errors that may be in the stream. + It is hard to know if we are simply out of sync or if there is + an error in the stream as we have no way to check the start or + end of the message (python just doesn't have the resolution to + check for millisecond delays). + """ + self._buffer = b'' + self._header = {'lrc': '0000', 'len': 0, 'uid': 0x00} + + def populateResult(self, result): + """ Populates the modbus result header + + The serial packets do not have any header information + that is copied. + + :param result: The response packet + """ + result.unit_id = self._header['uid'] + + # ----------------------------------------------------------------------- # + # Public Member Functions + # ----------------------------------------------------------------------- # + def processIncomingPacket(self, data, callback, unit, **kwargs): + """ + The new packet processing pattern + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. This handles the case when we read N + 1 or 1 // N + messages at a time instead of 1. + + The processed and decoded messages are pushed to the callback + function to process and send. + + :param data: The new packet data + :param callback: The function to send results to + :param unit: Process if unit id matches, ignore otherwise (could be a + list of unit ids (server) or single unit id(client/server)) + :param single: True or False (If True, ignore unit address validation) + + """ + if not isinstance(unit, (list, tuple)): + unit = [unit] + single = kwargs.get('single', False) + self.addToFrame(data) + while self.isFrameReady(): + if self.checkFrame(): + if self._validate_unit_id(unit, single): + frame = self.getFrame() + result = self.decoder.decode(frame) + if result is None: + raise ModbusIOException("Unable to decode response") + self.populateResult(result) + self.advanceFrame() + callback(result) # defer this + else: + _logger.error("Not a valid unit id - {}, " + "ignoring!!".format(self._header['uid'])) + self.resetFrame() + else: + break + + def buildPacket(self, message): + """ Creates a ready to send modbus packet + Built off of a modbus request/response + + :param message: The request/response to send + :return: The encoded packet + """ + encoded = message.encode() + buffer = struct.pack(ASCII_FRAME_HEADER, message.unit_id, + message.function_code) + checksum = computeLRC(encoded + buffer) + + packet = bytearray() + params = (message.unit_id, message.function_code) + packet.extend(self._start) + packet.extend(('%02x%02x' % params).encode()) + packet.extend(b2a_hex(encoded)) + packet.extend(('%02x' % checksum).encode()) + packet.extend(self._end) + return bytes(packet).upper() + + +# __END__ + diff --git a/modbus/pymodbus/framer/binary_framer.py b/modbus/pymodbus/framer/binary_framer.py new file mode 100644 index 00000000..ba6655b5 --- /dev/null +++ b/modbus/pymodbus/framer/binary_framer.py @@ -0,0 +1,227 @@ +import struct +from pymodbus.exceptions import ModbusIOException +from pymodbus.utilities import checkCRC, computeCRC +from pymodbus.framer import ModbusFramer, FRAME_HEADER, BYTE_ORDER + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + +BINARY_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER + +# --------------------------------------------------------------------------- # +# Modbus Binary Message +# --------------------------------------------------------------------------- # + + +class ModbusBinaryFramer(ModbusFramer): + """ + Modbus Binary Frame Controller:: + + [ Start ][Address ][ Function ][ Data ][ CRC ][ End ] + 1b 1b 1b Nb 2b 1b + + * data can be 0 - 2x252 chars + * end is '}' + * start is '{' + + The idea here is that we implement the RTU protocol, however, + instead of using timing for message delimiting, we use start + and end of message characters (in this case { and }). Basically, + this is a binary framer. + + The only case we have to watch out for is when a message contains + the { or } characters. If we encounter these characters, we + simply duplicate them. Hopefully we will not encounter those + characters that often and will save a little bit of bandwitch + without a real-time system. + + Protocol defined by jamod.sourceforge.net. + """ + + def __init__(self, decoder, client=None): + """ Initializes a new instance of the framer + + :param decoder: The decoder implementation to use + """ + self._buffer = b'' + self._header = {'crc': 0x0000, 'len': 0, 'uid': 0x00} + self._hsize = 0x01 + self._start = b'\x7b' # { + self._end = b'\x7d' # } + self._repeat = [b'}'[0], b'{'[0]] # python3 hack + self.decoder = decoder + self.client = client + + # ----------------------------------------------------------------------- # + # Private Helper Functions + # ----------------------------------------------------------------------- # + def decode_data(self, data): + if len(data) > self._hsize: + uid = struct.unpack('>B', data[1:2])[0] + fcode = struct.unpack('>B', data[2:3])[0] + return dict(unit=uid, fcode=fcode) + return dict() + + def checkFrame(self): + """ Check and decode the next frame + + :returns: True if we are successful, False otherwise + """ + start = self._buffer.find(self._start) + if start == -1: + return False + if start > 0: # go ahead and skip old bad data + self._buffer = self._buffer[start:] + + end = self._buffer.find(self._end) + if end != -1: + self._header['len'] = end + self._header['uid'] = struct.unpack('>B', self._buffer[1:2])[0] + self._header['crc'] = struct.unpack('>H', self._buffer[end - 2:end])[0] + data = self._buffer[start + 1:end - 2] + return checkCRC(data, self._header['crc']) + return False + + def advanceFrame(self): + """ Skip over the current framed message + This allows us to skip over the current message after we have processed + it or determined that it contains an error. It also has to reset the + current frame header handle + """ + self._buffer = self._buffer[self._header['len'] + 2:] + self._header = {'crc':0x0000, 'len':0, 'uid':0x00} + + def isFrameReady(self): + """ Check if we should continue decode logic + This is meant to be used in a while loop in the decoding phase to let + the decoder know that there is still data in the buffer. + + :returns: True if ready, False otherwise + """ + return len(self._buffer) > 1 + + def addToFrame(self, message): + """ Add the next message to the frame buffer + This should be used before the decoding while loop to add the received + data to the buffer handle. + + :param message: The most recent packet + """ + self._buffer += message + + def getFrame(self): + """ Get the next frame from the buffer + + :returns: The frame data or '' + """ + start = self._hsize + 1 + end = self._header['len'] - 2 + buffer = self._buffer[start:end] + if end > 0: + return buffer + return b'' + + def populateResult(self, result): + """ Populates the modbus result header + + The serial packets do not have any header information + that is copied. + + :param result: The response packet + """ + result.unit_id = self._header['uid'] + + # ----------------------------------------------------------------------- # + # Public Member Functions + # ----------------------------------------------------------------------- # + def processIncomingPacket(self, data, callback, unit, **kwargs): + """ + The new packet processing pattern + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. This handles the case when we read N + 1 or 1 // N + messages at a time instead of 1. + + The processed and decoded messages are pushed to the callback + function to process and send. + + :param data: The new packet data + :param callback: The function to send results to + :param unit: Process if unit id matches, ignore otherwise (could be a + list of unit ids (server) or single unit id(client/server) + :param single: True or False (If True, ignore unit address validation) + + """ + self.addToFrame(data) + if not isinstance(unit, (list, tuple)): + unit = [unit] + single = kwargs.get('single', False) + while self.isFrameReady(): + if self.checkFrame(): + if self._validate_unit_id(unit, single): + result = self.decoder.decode(self.getFrame()) + if result is None: + raise ModbusIOException("Unable to decode response") + self.populateResult(result) + self.advanceFrame() + callback(result) # defer or push to a thread? + else: + _logger.debug("Not a valid unit id - {}, " + "ignoring!!".format(self._header['uid'])) + self.resetFrame() + break + + else: + _logger.debug("Frame check failed, ignoring!!") + self.resetFrame() + break + + def buildPacket(self, message): + """ Creates a ready to send modbus packet + + :param message: The request/response to send + :returns: The encoded packet + """ + data = self._preflight(message.encode()) + packet = struct.pack(BINARY_FRAME_HEADER, + message.unit_id, + message.function_code) + data + packet += struct.pack(">H", computeCRC(packet)) + packet = self._start + packet + self._end + return packet + + def _preflight(self, data): + """ + Preflight buffer test + + This basically scans the buffer for start and end + tags and if found, escapes them. + + :param data: The message to escape + :returns: the escaped packet + """ + array = bytearray() + for d in data: + if d in self._repeat: + array.append(d) + array.append(d) + return bytes(array) + + def resetFrame(self): + """ Reset the entire message frame. + This allows us to skip ovver errors that may be in the stream. + It is hard to know if we are simply out of sync or if there is + an error in the stream as we have no way to check the start or + end of the message (python just doesn't have the resolution to + check for millisecond delays). + """ + self._buffer = b'' + self._header = {'crc': 0x0000, 'len': 0, 'uid': 0x00} + + +# __END__ diff --git a/modbus/pymodbus/framer/rtu_framer.py b/modbus/pymodbus/framer/rtu_framer.py new file mode 100644 index 00000000..6c246bae --- /dev/null +++ b/modbus/pymodbus/framer/rtu_framer.py @@ -0,0 +1,334 @@ +import struct +import time + +from pymodbus.exceptions import ModbusIOException +from pymodbus.exceptions import InvalidMessageReceivedException +from pymodbus.utilities import checkCRC, computeCRC +from pymodbus.utilities import hexlify_packets, ModbusTransactionState +from pymodbus.compat import byte2int +from pymodbus.framer import ModbusFramer, FRAME_HEADER, BYTE_ORDER + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + +RTU_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER + + +# --------------------------------------------------------------------------- # +# Modbus RTU Message +# --------------------------------------------------------------------------- # +class ModbusRtuFramer(ModbusFramer): + """ + Modbus RTU Frame controller:: + + [ Start Wait ] [Address ][ Function Code] [ Data ][ CRC ][ End Wait ] + 3.5 chars 1b 1b Nb 2b 3.5 chars + + Wait refers to the amount of time required to transmit at least x many + characters. In this case it is 3.5 characters. Also, if we receive a + wait of 1.5 characters at any point, we must trigger an error message. + Also, it appears as though this message is little endian. The logic is + simplified as the following:: + + block-on-read: + read until 3.5 delay + check for errors + decode + + The following table is a listing of the baud wait times for the specified + baud rates:: + + ------------------------------------------------------------------ + Baud 1.5c (18 bits) 3.5c (38 bits) + ------------------------------------------------------------------ + 1200 13333.3 us 31666.7 us + 4800 3333.3 us 7916.7 us + 9600 1666.7 us 3958.3 us + 19200 833.3 us 1979.2 us + 38400 416.7 us 989.6 us + ------------------------------------------------------------------ + 1 Byte = start + 8 bits + parity + stop = 11 bits + (1/Baud)(bits) = delay seconds + """ + + def __init__(self, decoder, client=None): + """ Initializes a new instance of the framer + + :param decoder: The decoder factory implementation to use + """ + self._buffer = b'' + self._header = {'uid': 0x00, 'len': 0, 'crc': b'\x00\x00'} + self._hsize = 0x01 + self._end = b'\x0d\x0a' + self._min_frame_size = 4 + self.decoder = decoder + self.client = client + + # ----------------------------------------------------------------------- # + # Private Helper Functions + # ----------------------------------------------------------------------- # + def decode_data(self, data): + if len(data) > self._hsize: + uid = byte2int(data[0]) + fcode = byte2int(data[1]) + return dict(unit=uid, fcode=fcode) + return dict() + + def checkFrame(self): + """ + Check if the next frame is available. + Return True if we were successful. + + 1. Populate header + 2. Discard frame if UID does not match + """ + try: + self.populateHeader() + frame_size = self._header['len'] + data = self._buffer[:frame_size - 2] + crc = self._header['crc'] + crc_val = (byte2int(crc[0]) << 8) + byte2int(crc[1]) + return checkCRC(data, crc_val) + except (IndexError, KeyError, struct.error): + return False + + def advanceFrame(self): + """ + Skip over the current framed message + This allows us to skip over the current message after we have processed + it or determined that it contains an error. It also has to reset the + current frame header handle + """ + + self._buffer = self._buffer[self._header['len']:] + _logger.debug("Frame advanced, resetting header!!") + self._header = {'uid': 0x00, 'len': 0, 'crc': b'\x00\x00'} + + def resetFrame(self): + """ + Reset the entire message frame. + This allows us to skip over errors that may be in the stream. + It is hard to know if we are simply out of sync or if there is + an error in the stream as we have no way to check the start or + end of the message (python just doesn't have the resolution to + check for millisecond delays). + """ + _logger.debug("Resetting frame - Current Frame in " + "buffer - {}".format(hexlify_packets(self._buffer))) + self._buffer = b'' + self._header = {'uid': 0x00, 'len': 0, 'crc': b'\x00\x00'} + + def isFrameReady(self): + """ + Check if we should continue decode logic + This is meant to be used in a while loop in the decoding phase to let + the decoder know that there is still data in the buffer. + + :returns: True if ready, False otherwise + """ + if len(self._buffer) <= self._hsize: + return False + + try: + # Frame is ready only if populateHeader() successfully populates crc field which finishes RTU frame + # Otherwise, if buffer is not yet long enough, populateHeader() raises IndexError + self.populateHeader() + except IndexError: + return False + + return True + + def populateHeader(self, data=None): + """ + Try to set the headers `uid`, `len` and `crc`. + + This method examines `self._buffer` and writes meta + information into `self._header`. + + Beware that this method will raise an IndexError if + `self._buffer` is not yet long enough. + """ + data = data if data is not None else self._buffer + self._header['uid'] = byte2int(data[0]) + func_code = byte2int(data[1]) + pdu_class = self.decoder.lookupPduClass(func_code) + size = pdu_class.calculateRtuFrameSize(data) + self._header['len'] = size + + if len(data) < size: + # crc yet not available + raise IndexError + self._header['crc'] = data[size - 2:size] + + def addToFrame(self, message): + """ + This should be used before the decoding while loop to add the received + data to the buffer handle. + + :param message: The most recent packet + """ + self._buffer += message + + def getFrame(self): + """ + Get the next frame from the buffer + + :returns: The frame data or '' + """ + start = self._hsize + end = self._header['len'] - 2 + buffer = self._buffer[start:end] + if end > 0: + _logger.debug("Getting Frame - {}".format(hexlify_packets(buffer))) + return buffer + return b'' + + def populateResult(self, result): + """ + Populates the modbus result header + + The serial packets do not have any header information + that is copied. + + :param result: The response packet + """ + result.unit_id = self._header['uid'] + result.transaction_id = self._header['uid'] + + # ----------------------------------------------------------------------- # + # Public Member Functions + # ----------------------------------------------------------------------- # + def processIncomingPacket(self, data, callback, unit, **kwargs): + """ + The new packet processing pattern + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. This handles the case when we read N + 1 or 1 // N + messages at a time instead of 1. + + The processed and decoded messages are pushed to the callback + function to process and send. + + :param data: The new packet data + :param callback: The function to send results to + :param unit: Process if unit id matches, ignore otherwise (could be a + list of unit ids (server) or single unit id(client/server) + :param single: True or False (If True, ignore unit address validation) + + """ + if not isinstance(unit, (list, tuple)): + unit = [unit] + self.addToFrame(data) + single = kwargs.get("single", False) + if self.isFrameReady(): + if self.checkFrame(): + if self._validate_unit_id(unit, single): + self._process(callback) + else: + _logger.debug("Not a valid unit id - {}, " + "ignoring!!".format(self._header['uid'])) + self.resetFrame() + else: + _logger.debug("Frame check failed, ignoring!!") + self.resetFrame() + else: + _logger.debug("Frame - [{}] not ready".format(data)) + + def buildPacket(self, message): + """ + Creates a ready to send modbus packet + + :param message: The populated request/response to send + """ + data = message.encode() + packet = struct.pack(RTU_FRAME_HEADER, + message.unit_id, + message.function_code) + data + packet += struct.pack(">H", computeCRC(packet)) + message.transaction_id = message.unit_id # Ensure that transaction is actually the unit id for serial comms + return packet + + def sendPacket(self, message): + """ + Sends packets on the bus with 3.5char delay between frames + :param message: Message to be sent over the bus + :return: + """ + start = time.time() + timeout = start + self.client.timeout + while self.client.state != ModbusTransactionState.IDLE: + if self.client.state == ModbusTransactionState.TRANSACTION_COMPLETE: + ts = round(time.time(), 6) + _logger.debug("Changing state to IDLE - Last Frame End - {}, " + "Current Time stamp - {}".format( + self.client.last_frame_end, ts) + ) + + if self.client.last_frame_end: + idle_time = self.client.idle_time() + if round(ts - idle_time, 6) <= self.client.silent_interval: + _logger.debug("Waiting for 3.5 char before next " + "send - {} ms".format( + self.client.silent_interval * 1000) + ) + time.sleep(self.client.silent_interval) + else: + # Recovering from last error ?? + time.sleep(self.client.silent_interval) + self.client.state = ModbusTransactionState.IDLE + elif self.client.state == ModbusTransactionState.RETRYING: + # Simple lets settle down!!! + # To check for higher baudrates + time.sleep(self.client.timeout) + break + else: + if time.time() > timeout: + _logger.debug("Spent more time than the read time out, " + "resetting the transaction to IDLE") + self.client.state = ModbusTransactionState.IDLE + else: + _logger.debug("Sleeping") + time.sleep(self.client.silent_interval) + size = self.client.send(message) + self.client.last_frame_end = round(time.time(), 6) + return size + + def recvPacket(self, size): + """ + Receives packet from the bus with specified len + :param size: Number of bytes to read + :return: + """ + result = self.client.recv(size) + self.client.last_frame_end = round(time.time(), 6) + return result + + def _process(self, callback, error=False): + """ + Process incoming packets irrespective error condition + """ + data = self.getRawFrame() if error else self.getFrame() + result = self.decoder.decode(data) + if result is None: + raise ModbusIOException("Unable to decode request") + elif error and result.function_code < 0x80: + raise InvalidMessageReceivedException(result) + else: + self.populateResult(result) + self.advanceFrame() + callback(result) # defer or push to a thread? + + def getRawFrame(self): + """ + Returns the complete buffer + """ + _logger.debug("Getting Raw Frame - " + "{}".format(hexlify_packets(self._buffer))) + return self._buffer + +# __END__ diff --git a/modbus/pymodbus/framer/socket_framer.py b/modbus/pymodbus/framer/socket_framer.py new file mode 100644 index 00000000..e6718973 --- /dev/null +++ b/modbus/pymodbus/framer/socket_framer.py @@ -0,0 +1,217 @@ +import struct +from pymodbus.exceptions import ModbusIOException +from pymodbus.exceptions import InvalidMessageReceivedException +from pymodbus.utilities import hexlify_packets +from pymodbus.framer import ModbusFramer, SOCKET_FRAME_HEADER + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- # +# Modbus TCP Message +# --------------------------------------------------------------------------- # + + +class ModbusSocketFramer(ModbusFramer): + """ Modbus Socket Frame controller + + Before each modbus TCP message is an MBAP header which is used as a + message frame. It allows us to easily separate messages as follows:: + + [ MBAP Header ] [ Function Code] [ Data ] \ + [ tid ][ pid ][ length ][ uid ] + 2b 2b 2b 1b 1b Nb + + while len(message) > 0: + tid, pid, length`, uid = struct.unpack(">HHHB", message) + request = message[0:7 + length - 1`] + message = [7 + length - 1:] + + * length = uid + function code + data + * The -1 is to account for the uid byte + """ + + def __init__(self, decoder, client=None): + """ Initializes a new instance of the framer + + :param decoder: The decoder factory implementation to use + """ + self._buffer = b'' + self._header = {'tid': 0, 'pid': 0, 'len': 0, 'uid': 0} + self._hsize = 0x07 + self.decoder = decoder + self.client = client + + # ----------------------------------------------------------------------- # + # Private Helper Functions + # ----------------------------------------------------------------------- # + def checkFrame(self): + """ + Check and decode the next frame Return true if we were successful + """ + if self.isFrameReady(): + (self._header['tid'], self._header['pid'], + self._header['len'], self._header['uid']) = struct.unpack( + '>HHHB', self._buffer[0:self._hsize]) + + # someone sent us an error? ignore it + if self._header['len'] < 2: + self.advanceFrame() + # we have at least a complete message, continue + elif len(self._buffer) - self._hsize + 1 >= self._header['len']: + return True + # we don't have enough of a message yet, wait + return False + + def advanceFrame(self): + """ Skip over the current framed message + This allows us to skip over the current message after we have processed + it or determined that it contains an error. It also has to reset the + current frame header handle + """ + length = self._hsize + self._header['len'] - 1 + self._buffer = self._buffer[length:] + self._header = {'tid': 0, 'pid': 0, 'len': 0, 'uid': 0} + + def isFrameReady(self): + """ Check if we should continue decode logic + This is meant to be used in a while loop in the decoding phase to let + the decoder factory know that there is still data in the buffer. + + :returns: True if ready, False otherwise + """ + return len(self._buffer) > self._hsize + + def addToFrame(self, message): + """ Adds new packet data to the current frame buffer + + :param message: The most recent packet + """ + self._buffer += message + + def getFrame(self): + """ Return the next frame from the buffered data + + :returns: The next full frame buffer + """ + length = self._hsize + self._header['len'] - 1 + return self._buffer[self._hsize:length] + + def populateResult(self, result): + """ + Populates the modbus result with the transport specific header + information (pid, tid, uid, checksum, etc) + + :param result: The response packet + """ + result.transaction_id = self._header['tid'] + result.protocol_id = self._header['pid'] + result.unit_id = self._header['uid'] + + # ----------------------------------------------------------------------- # + # Public Member Functions + # ----------------------------------------------------------------------- # + def decode_data(self, data): + if len(data) > self._hsize: + tid, pid, length, uid, fcode = struct.unpack(SOCKET_FRAME_HEADER, + data[0:self._hsize+1]) + return dict(tid=tid, pid=pid, length=length, unit=uid, fcode=fcode) + return dict() + + def processIncomingPacket(self, data, callback, unit, **kwargs): + """ + The new packet processing pattern + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. This handles the case when we read N + 1 or 1 // N + messages at a time instead of 1. + + The processed and decoded messages are pushed to the callback + function to process and send. + + :param data: The new packet data + :param callback: The function to send results to + :param unit: Process if unit id matches, ignore otherwise (could be a + list of unit ids (server) or single unit id(client/server) + :param single: True or False (If True, ignore unit address validation) + :return: + """ + if not isinstance(unit, (list, tuple)): + unit = [unit] + single = kwargs.get("single", False) + _logger.debug("Processing: " + hexlify_packets(data)) + self.addToFrame(data) + while True: + if self.isFrameReady(): + if self.checkFrame(): + if self._validate_unit_id(unit, single): + self._process(callback) + else: + _logger.debug("Not a valid unit id - {}, " + "ignoring!!".format(self._header['uid'])) + self.resetFrame() + else: + _logger.debug("Frame check failed, ignoring!!") + self.resetFrame() + else: + if len(self._buffer): + # Possible error ??? + if self._header['len'] < 2: + self._process(callback, error=True) + break + + def _process(self, callback, error=False): + """ + Process incoming packets irrespective error condition + """ + data = self.getRawFrame() if error else self.getFrame() + result = self.decoder.decode(data) + if result is None: + raise ModbusIOException("Unable to decode request") + elif error and result.function_code < 0x80: + raise InvalidMessageReceivedException(result) + else: + self.populateResult(result) + self.advanceFrame() + callback(result) # defer or push to a thread? + + def resetFrame(self): + """ + Reset the entire message frame. + This allows us to skip ovver errors that may be in the stream. + It is hard to know if we are simply out of sync or if there is + an error in the stream as we have no way to check the start or + end of the message (python just doesn't have the resolution to + check for millisecond delays). + """ + self._buffer = b'' + self._header = {'tid': 0, 'pid': 0, 'len': 0, 'uid': 0} + + def getRawFrame(self): + """ + Returns the complete buffer + """ + return self._buffer + + def buildPacket(self, message): + """ Creates a ready to send modbus packet + + :param message: The populated request/response to send + """ + data = message.encode() + packet = struct.pack(SOCKET_FRAME_HEADER, + message.transaction_id, + message.protocol_id, + len(data) + 2, + message.unit_id, + message.function_code) + packet += data + return packet + + +# __END__ diff --git a/modbus/pymodbus/framer/tls_framer.py b/modbus/pymodbus/framer/tls_framer.py new file mode 100644 index 00000000..33bd48d8 --- /dev/null +++ b/modbus/pymodbus/framer/tls_framer.py @@ -0,0 +1,185 @@ +import struct +from pymodbus.exceptions import ModbusIOException +from pymodbus.exceptions import InvalidMessageReceivedException +from pymodbus.utilities import hexlify_packets +from pymodbus.framer import ModbusFramer, TLS_FRAME_HEADER + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- # +# Modbus TLS Message +# --------------------------------------------------------------------------- # + + +class ModbusTlsFramer(ModbusFramer): + """ Modbus TLS Frame controller + + No prefix MBAP header before decrypted PDU is used as a message frame for + Modbus Security Application Protocol. It allows us to easily separate + decrypted messages which is PDU as follows: + + [ Function Code] [ Data ] + 1b Nb + """ + + def __init__(self, decoder, client=None): + """ Initializes a new instance of the framer + + :param decoder: The decoder factory implementation to use + """ + self._buffer = b'' + self._header = {} + self._hsize = 0x0 + self.decoder = decoder + self.client = client + + # ----------------------------------------------------------------------- # + # Private Helper Functions + # ----------------------------------------------------------------------- # + def checkFrame(self): + """ + Check and decode the next frame Return true if we were successful + """ + if self.isFrameReady(): + # we have at least a complete message, continue + if len(self._buffer) - self._hsize >= 1: + return True + # we don't have enough of a message yet, wait + return False + + def advanceFrame(self): + """ Skip over the current framed message + This allows us to skip over the current message after we have processed + it or determined that it contains an error. It also has to reset the + current frame header handle + """ + self._buffer = b'' + self._header = {} + + def isFrameReady(self): + """ Check if we should continue decode logic + This is meant to be used in a while loop in the decoding phase to let + the decoder factory know that there is still data in the buffer. + + :returns: True if ready, False otherwise + """ + return len(self._buffer) > self._hsize + + def addToFrame(self, message): + """ Adds new packet data to the current frame buffer + + :param message: The most recent packet + """ + self._buffer += message + + def getFrame(self): + """ Return the next frame from the buffered data + + :returns: The next full frame buffer + """ + return self._buffer[self._hsize:] + + def populateResult(self, result): + """ + Populates the modbus result with the transport specific header + information (no header before PDU in decrypted message) + + :param result: The response packet + """ + return + + # ----------------------------------------------------------------------- # + # Public Member Functions + # ----------------------------------------------------------------------- # + def decode_data(self, data): + if len(data) > self._hsize: + (fcode,) = struct.unpack(TLS_FRAME_HEADER, data[0:self._hsize+1]) + return dict(fcode=fcode) + return dict() + + def processIncomingPacket(self, data, callback, unit, **kwargs): + """ + The new packet processing pattern + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. This handles the case when we read N + 1 or 1 // N + messages at a time instead of 1. + + The processed and decoded messages are pushed to the callback + function to process and send. + + :param data: The new packet data + :param callback: The function to send results to + :param unit: Process if unit id matches, ignore otherwise (could be a + list of unit ids (server) or single unit id(client/server) + :param single: True or False (If True, ignore unit address validation) + :return: + """ + if not isinstance(unit, (list, tuple)): + unit = [unit] + # no unit id for Modbus Security Application Protocol + single = kwargs.get("single", True) + _logger.debug("Processing: " + hexlify_packets(data)) + self.addToFrame(data) + + if self.isFrameReady(): + if self.checkFrame(): + if self._validate_unit_id(unit, single): + self._process(callback) + else: + _logger.debug("Not in valid unit id - {}, " + "ignoring!!".format(unit)) + self.resetFrame() + else: + _logger.debug("Frame check failed, ignoring!!") + self.resetFrame() + + def _process(self, callback, error=False): + """ + Process incoming packets irrespective error condition + """ + data = self.getRawFrame() if error else self.getFrame() + result = self.decoder.decode(data) + if result is None: + raise ModbusIOException("Unable to decode request") + elif error and result.function_code < 0x80: + raise InvalidMessageReceivedException(result) + else: + self.populateResult(result) + self.advanceFrame() + callback(result) # defer or push to a thread? + + def resetFrame(self): + """ + Reset the entire message frame. + This allows us to skip ovver errors that may be in the stream. + It is hard to know if we are simply out of sync or if there is + an error in the stream as we have no way to check the start or + end of the message (python just doesn't have the resolution to + check for millisecond delays). + """ + self._buffer = b'' + + def getRawFrame(self): + """ + Returns the complete buffer + """ + return self._buffer + + def buildPacket(self, message): + """ Creates a ready to send modbus packet + + :param message: The populated request/response to send + """ + data = message.encode() + packet = struct.pack(TLS_FRAME_HEADER, message.function_code) + packet += data + return packet + +# __END__ diff --git a/modbus/pymodbus/interfaces.py b/modbus/pymodbus/interfaces.py new file mode 100644 index 00000000..49e6939c --- /dev/null +++ b/modbus/pymodbus/interfaces.py @@ -0,0 +1,248 @@ +""" +Pymodbus Interfaces +--------------------- + +A collection of base classes that are used throughout +the pymodbus library. +""" +from pymodbus.exceptions import (NotImplementedException, + MessageRegisterException) + + +# --------------------------------------------------------------------------- # +# Generic +# --------------------------------------------------------------------------- # +class Singleton(object): + """ + Singleton base class + http://mail.python.org/pipermail/python-list/2007-July/450681.html + """ + def __new__(cls, *args, **kwargs): + """ Create a new instance + """ + if '_inst' not in vars(cls): + cls._inst = object.__new__(cls) + return cls._inst + + +# --------------------------------------------------------------------------- # +# Project Specific +# --------------------------------------------------------------------------- # +class IModbusDecoder(object): + """ Modbus Decoder Base Class + + This interface must be implemented by a modbus message + decoder factory. These factories are responsible for + abstracting away converting a raw packet into a request / response + message object. + """ + + def decode(self, message): + """ Wrapper to decode a given packet + + :param message: The raw modbus request packet + :return: The decoded modbus message or None if error + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def lookupPduClass(self, function_code): + """ Use `function_code` to determine the class of the PDU. + + :param function_code: The function code specified in a frame. + :returns: The class of the PDU that has a matching `function_code`. + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def register(self, function=None): + """ + Registers a function and sub function class with the decoder + :param function: Custom function class to register + :return: + """ + raise NotImplementedException( + "Method not implemented by derived class") + + +class IModbusFramer(object): + """ + A framer strategy interface. The idea is that we abstract away all the + detail about how to detect if a current message frame exists, decoding + it, sending it, etc so that we can plug in a new Framer object (tcp, + rtu, ascii). + """ + + def checkFrame(self): + """ Check and decode the next frame + + :returns: True if we successful, False otherwise + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def advanceFrame(self): + """ Skip over the current framed message + This allows us to skip over the current message after we have processed + it or determined that it contains an error. It also has to reset the + current frame header handle + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def addToFrame(self, message): + """ Add the next message to the frame buffer + + This should be used before the decoding while loop to add the received + data to the buffer handle. + + :param message: The most recent packet + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def isFrameReady(self): + """ Check if we should continue decode logic + + This is meant to be used in a while loop in the decoding phase to let + the decoder know that there is still data in the buffer. + + :returns: True if ready, False otherwise + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def getFrame(self): + """ Get the next frame from the buffer + + :returns: The frame data or '' + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def populateResult(self, result): + """ Populates the modbus result with current frame header + + We basically copy the data back over from the current header + to the result header. This may not be needed for serial messages. + + :param result: The response packet + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def processIncomingPacket(self, data, callback): + """ The new packet processing pattern + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. This handles the case when we read N + 1 or 1 / N + messages at a time instead of 1. + + The processed and decoded messages are pushed to the callback + function to process and send. + + :param data: The new packet data + :param callback: The function to send results to + """ + raise NotImplementedException( + "Method not implemented by derived class") + + def buildPacket(self, message): + """ Creates a ready to send modbus packet + + The raw packet is built off of a fully populated modbus + request / response message. + + :param message: The request/response to send + :returns: The built packet + """ + raise NotImplementedException( + "Method not implemented by derived class") + + +class IModbusSlaveContext(object): + """ + Interface for a modbus slave data context + + Derived classes must implemented the following methods: + reset(self) + validate(self, fx, address, count=1) + getValues(self, fx, address, count=1) + setValues(self, fx, address, values) + """ + __fx_mapper = {2: 'd', 4: 'i'} + __fx_mapper.update([(i, 'h') for i in [3, 6, 16, 22, 23]]) + __fx_mapper.update([(i, 'c') for i in [1, 5, 15]]) + + def decode(self, fx): + """ Converts the function code to the datastore to + + :param fx: The function we are working with + :returns: one of [d(iscretes),i(nputs),h(olding),c(oils) + """ + return self.__fx_mapper[fx] + + def reset(self): + """ Resets all the datastores to their default values + """ + raise NotImplementedException("Context Reset") + + def validate(self, fx, address, count=1): + """ Validates the request to make sure it is in range + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to test + :returns: True if the request in within range, False otherwise + """ + raise NotImplementedException("validate context values") + + def getValues(self, fx, address, count=1): + """ Get `count` values from datastore + + :param fx: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + """ + raise NotImplementedException("get context values") + + def setValues(self, fx, address, values): + """ Sets the datastore with the supplied values + + :param fx: The function we are working with + :param address: The starting address + :param values: The new values to be set + """ + raise NotImplementedException("set context values") + + +class IPayloadBuilder(object): + """ + This is an interface to a class that can build a payload + for a modbus register write command. It should abstract + the codec for encoding data to the required format + (bcd, binary, char, etc). + """ + + def build(self): + """ Return the payload buffer as a list + + This list is two bytes per element and can + thus be treated as a list of registers. + + :returns: The payload buffer as a list + """ + raise NotImplementedException("set context values") + + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # +__all__ = [ + 'Singleton', + 'IModbusDecoder', 'IModbusFramer', 'IModbusSlaveContext', + 'IPayloadBuilder', +] diff --git a/modbus/pymodbus/internal/__init__.py b/modbus/pymodbus/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modbus/pymodbus/internal/ptwisted.py b/modbus/pymodbus/internal/ptwisted.py new file mode 100644 index 00000000..fdd15a6b --- /dev/null +++ b/modbus/pymodbus/internal/ptwisted.py @@ -0,0 +1,42 @@ +''' +A collection of twisted utility code +''' +from pymodbus.compat import IS_PYTHON2, IS_PYTHON3 +if IS_PYTHON2: + from twisted.cred import portal, checkers + from twisted.conch import manhole, manhole_ssh + from twisted.conch.insults import insults + +#---------------------------------------------------------------------------# +# Logging +#---------------------------------------------------------------------------# +import logging +_logger = logging.getLogger(__name__) + + +#---------------------------------------------------------------------------# +# Twisted Helper Methods +#---------------------------------------------------------------------------# +def InstallManagementConsole(namespace, users={'admin': 'admin'}, port=503): + ''' Helper method to start an ssh management console + for the modbus server. + + :param namespace: The data to constrain the server to + :param users: The users to login with + :param port: The port to host the server on + ''' + if IS_PYTHON3: + raise NotImplemented("This code currently doesn't work on python3") + from twisted.internet import reactor + + def build_protocol(): + p = insults.ServerProtocol(manhole.ColoredManhole, namespace) + return p + + r = manhole_ssh.TerminalRealm() + r.chainedProtocolFactory = build_protocol + c = checkers.InMemoryUsernamePasswordDatabaseDontUse(**users) + p = portal.Portal(r, [c]) + factory = manhole_ssh.ConchFactory(p) + reactor.listenTCP(port, factory) + diff --git a/modbus/pymodbus/mei_message.py b/modbus/pymodbus/mei_message.py new file mode 100644 index 00000000..44f70edf --- /dev/null +++ b/modbus/pymodbus/mei_message.py @@ -0,0 +1,213 @@ +''' +Encapsulated Interface (MEI) Transport Messages +----------------------------------------------- + +''' +import struct +from pymodbus.constants import DeviceInformation, MoreData +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.device import ModbusControlBlock +from pymodbus.device import DeviceInformationFactory +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.compat import iteritems, byte2int, IS_PYTHON3 + +_MCB = ModbusControlBlock() + + +class _OutOfSpaceException(Exception): + # This exception exists here as a simple, local way to manage response + # length control for the only MODBUS command which requires it under + # standard, non-error conditions. It and the structures associated with + # it should ideally be refactored and applied to all responses, however, + # since a Client can make requests which result in disallowed conditions, + # such as, for instance, requesting a register read of more registers + # than will fit in a single PDU. As per the specification, the PDU is + # restricted to 253 bytes, irrespective of the transport used. + # + # See Page 5/50 of MODBUS Application Protocol Specification V1.1b3. + def __init__(self, oid): + self.oid = oid + + +#---------------------------------------------------------------------------# +# Read Device Information +#---------------------------------------------------------------------------# +class ReadDeviceInformationRequest(ModbusRequest): + ''' + This function code allows reading the identification and additional + information relative to the physical and functional description of a + remote device, only. + + The Read Device Identification interface is modeled as an address space + composed of a set of addressable data elements. The data elements are + called objects and an object Id identifies them. + ''' + function_code = 0x2b + sub_function_code = 0x0e + _rtu_frame_size = 7 + + def __init__(self, read_code=None, object_id=0x00, **kwargs): + ''' Initializes a new instance + + :param read_code: The device information read code + :param object_id: The object to read from + ''' + ModbusRequest.__init__(self, **kwargs) + self.read_code = read_code or DeviceInformation.Basic + self.object_id = object_id + + def encode(self): + ''' Encodes the request packet + + :returns: The byte encoded packet + ''' + packet = struct.pack('>BBB', self.sub_function_code, + self.read_code, self.object_id) + return packet + + def decode(self, data): + ''' Decodes data part of the message. + + :param data: The incoming data + ''' + params = struct.unpack('>BBB', data) + self.sub_function_code, self.read_code, self.object_id = params + + def execute(self, context): + ''' Run a read exeception status request against the store + + :param context: The datastore to request from + :returns: The populated response + ''' + if not (0x00 <= self.object_id <= 0xff): + return self.doException(merror.IllegalValue) + if not (0x00 <= self.read_code <= 0x04): + return self.doException(merror.IllegalValue) + + information = DeviceInformationFactory.get(_MCB, + self.read_code, self.object_id) + return ReadDeviceInformationResponse(self.read_code, information) + + def __str__(self): + ''' Builds a representation of the request + + :returns: The string representation of the request + ''' + params = (self.read_code, self.object_id) + return "ReadDeviceInformationRequest(%d,%d)" % params + + +class ReadDeviceInformationResponse(ModbusResponse): + ''' + ''' + function_code = 0x2b + sub_function_code = 0x0e + + @classmethod + def calculateRtuFrameSize(cls, buffer): + ''' Calculates the size of the message + + :param buffer: A buffer containing the data that have been received. + :returns: The number of bytes in the response. + ''' + size = 8 # skip the header information + count = byte2int(buffer[7]) + + while count > 0: + _, object_length = struct.unpack('>BB', buffer[size:size+2]) + size += object_length + 2 + count -= 1 + return size + 2 + + def __init__(self, read_code=None, information=None, **kwargs): + ''' Initializes a new instance + + :param read_code: The device information read code + :param information: The requested information request + ''' + ModbusResponse.__init__(self, **kwargs) + self.read_code = read_code or DeviceInformation.Basic + self.information = information or {} + self.number_of_objects = 0 + self.conformity = 0x83 # I support everything right now + self.next_object_id = 0x00 + self.more_follows = MoreData.Nothing + self.space_left = None + + def _encode_object(self, object_id, data): + self.space_left -= (2 + len(data)) + if self.space_left <= 0: + raise _OutOfSpaceException(object_id) + encoded_obj = struct.pack('>BB', object_id, len(data)) + if IS_PYTHON3: + if isinstance(data, bytes): + encoded_obj += data + else: + encoded_obj += data.encode() + else: + encoded_obj += data.encode() + self.number_of_objects += 1 + return encoded_obj + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + packet = struct.pack('>BBB', self.sub_function_code, + self.read_code, self.conformity) + self.space_left = 253 - 6 + objects = b'' + try: + for (object_id, data) in iteritems(self.information): + if isinstance(data, list): + for item in data: + objects += self._encode_object(object_id, item) + else: + objects += self._encode_object(object_id, data) + except _OutOfSpaceException as e: + self.next_object_id = e.oid + self.more_follows = MoreData.KeepReading + + packet += struct.pack('>BBB', self.more_follows, self.next_object_id, + self.number_of_objects) + packet += objects + return packet + + def decode(self, data): + ''' Decodes a the response + + :param data: The packet data to decode + ''' + params = struct.unpack('>BBBBBB', data[0:6]) + self.sub_function_code, self.read_code = params[0:2] + self.conformity, self.more_follows = params[2:4] + self.next_object_id, self.number_of_objects = params[4:6] + self.information, count = {}, 6 # skip the header information + + while count < len(data): + object_id, object_length = struct.unpack('>BB', data[count:count+2]) + count += object_length + 2 + if object_id not in self.information.keys(): + self.information[object_id] = data[count-object_length:count] + else: + if isinstance(self.information[object_id], list): + self.information[object_id].append(data[count-object_length:count]) + else: + self.information[object_id] = [self.information[object_id], + data[count - object_length:count]] + + def __str__(self): + ''' Builds a representation of the response + + :returns: The string representation of the response + ''' + return "ReadDeviceInformationResponse(%d)" % self.read_code + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "ReadDeviceInformationRequest", "ReadDeviceInformationResponse", +] diff --git a/modbus/pymodbus/other_message.py b/modbus/pymodbus/other_message.py new file mode 100644 index 00000000..31e6734b --- /dev/null +++ b/modbus/pymodbus/other_message.py @@ -0,0 +1,453 @@ +''' +Diagnostic record read/write + +Currently not all implemented +''' +import struct +from pymodbus.constants import ModbusStatus +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.device import ModbusControlBlock, DeviceInformationFactory +from pymodbus.compat import byte2int, int2byte + +_MCB = ModbusControlBlock() + + +#---------------------------------------------------------------------------# +# TODO Make these only work on serial +#---------------------------------------------------------------------------# +class ReadExceptionStatusRequest(ModbusRequest): + ''' + This function code is used to read the contents of eight Exception Status + outputs in a remote device. The function provides a simple method for + accessing this information, because the Exception Output references are + known (no output reference is needed in the function). + ''' + function_code = 0x07 + _rtu_frame_size = 4 + + def __init__(self, **kwargs): + ''' Initializes a new instance + ''' + ModbusRequest.__init__(self, **kwargs) + + def encode(self): + ''' Encodes the message + ''' + return b'' + + def decode(self, data): + ''' Decodes data part of the message. + + :param data: The incoming data + ''' + pass + + def execute(self, context=None): + ''' Run a read exeception status request against the store + + :returns: The populated response + ''' + status = _MCB.Counter.summary() + return ReadExceptionStatusResponse(status) + + def __str__(self): + ''' Builds a representation of the request + + :returns: The string representation of the request + ''' + return "ReadExceptionStatusRequest(%d)" % (self.function_code) + + +class ReadExceptionStatusResponse(ModbusResponse): + ''' + The normal response contains the status of the eight Exception Status + outputs. The outputs are packed into one data byte, with one bit + per output. The status of the lowest output reference is contained + in the least significant bit of the byte. The contents of the eight + Exception Status outputs are device specific. + ''' + function_code = 0x07 + _rtu_frame_size = 5 + + def __init__(self, status=0x00, **kwargs): + ''' Initializes a new instance + + :param status: The status response to report + ''' + ModbusResponse.__init__(self, **kwargs) + self.status = status + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + return struct.pack('>B', self.status) + + def decode(self, data): + ''' Decodes a the response + + :param data: The packet data to decode + ''' + self.status = byte2int(data[0]) + + def __str__(self): + ''' Builds a representation of the response + + :returns: The string representation of the response + ''' + arguments = (self.function_code, self.status) + return "ReadExceptionStatusResponse(%d, %s)" % arguments + +# Encapsulate interface transport 43, 14 +# CANopen general reference 43, 13 + + +#---------------------------------------------------------------------------# +# TODO Make these only work on serial +#---------------------------------------------------------------------------# +class GetCommEventCounterRequest(ModbusRequest): + ''' + This function code is used to get a status word and an event count from + the remote device's communication event counter. + + By fetching the current count before and after a series of messages, a + client can determine whether the messages were handled normally by the + remote device. + + The device's event counter is incremented once for each successful + message completion. It is not incremented for exception responses, + poll commands, or fetch event counter commands. + + The event counter can be reset by means of the Diagnostics function + (code 08), with a subfunction of Restart Communications Option + (code 00 01) or Clear Counters and Diagnostic Register (code 00 0A). + ''' + function_code = 0x0b + _rtu_frame_size = 4 + + def __init__(self, **kwargs): + ''' Initializes a new instance + ''' + ModbusRequest.__init__(self, **kwargs) + + def encode(self): + ''' Encodes the message + ''' + return b'' + + def decode(self, data): + ''' Decodes data part of the message. + + :param data: The incoming data + ''' + pass + + def execute(self, context=None): + ''' Run a read exeception status request against the store + + :returns: The populated response + ''' + status = _MCB.Counter.Event + return GetCommEventCounterResponse(status) + + def __str__(self): + ''' Builds a representation of the request + + :returns: The string representation of the request + ''' + return "GetCommEventCounterRequest(%d)" % (self.function_code) + + +class GetCommEventCounterResponse(ModbusResponse): + ''' + The normal response contains a two-byte status word, and a two-byte + event count. The status word will be all ones (FF FF hex) if a + previously-issued program command is still being processed by the + remote device (a busy condition exists). Otherwise, the status word + will be all zeros. + ''' + function_code = 0x0b + _rtu_frame_size = 8 + + def __init__(self, count=0x0000, **kwargs): + ''' Initializes a new instance + + :param count: The current event counter value + ''' + ModbusResponse.__init__(self, **kwargs) + self.count = count + self.status = True # this means we are ready, not waiting + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + if self.status: ready = ModbusStatus.Ready + else: ready = ModbusStatus.Waiting + return struct.pack('>HH', ready, self.count) + + def decode(self, data): + ''' Decodes a the response + + :param data: The packet data to decode + ''' + ready, self.count = struct.unpack('>HH', data) + self.status = (ready == ModbusStatus.Ready) + + def __str__(self): + ''' Builds a representation of the response + + :returns: The string representation of the response + ''' + arguments = (self.function_code, self.count, self.status) + return "GetCommEventCounterResponse(%d, %d, %d)" % arguments + + +#---------------------------------------------------------------------------# +# TODO Make these only work on serial +#---------------------------------------------------------------------------# +class GetCommEventLogRequest(ModbusRequest): + ''' + This function code is used to get a status word, event count, message + count, and a field of event bytes from the remote device. + + The status word and event counts are identical to that returned by + the Get Communications Event Counter function (11, 0B hex). + + The message counter contains the quantity of messages processed by the + remote device since its last restart, clear counters operation, or + power-up. This count is identical to that returned by the Diagnostic + function (code 08), sub-function Return Bus Message Count (code 11, + 0B hex). + + The event bytes field contains 0-64 bytes, with each byte corresponding + to the status of one MODBUS send or receive operation for the remote + device. The remote device enters the events into the field in + chronological order. Byte 0 is the most recent event. Each new byte + flushes the oldest byte from the field. + ''' + function_code = 0x0c + _rtu_frame_size = 4 + + def __init__(self, **kwargs): + ''' Initializes a new instance + ''' + ModbusRequest.__init__(self, **kwargs) + + def encode(self): + ''' Encodes the message + ''' + return b'' + + def decode(self, data): + ''' Decodes data part of the message. + + :param data: The incoming data + ''' + pass + + def execute(self, context=None): + ''' Run a read exeception status request against the store + + :returns: The populated response + ''' + results = { + 'status' : True, + 'message_count' : _MCB.Counter.BusMessage, + 'event_count' : _MCB.Counter.Event, + 'events' : _MCB.getEvents(), + } + return GetCommEventLogResponse(**results) + + def __str__(self): + ''' Builds a representation of the request + + :returns: The string representation of the request + ''' + return "GetCommEventLogRequest(%d)" % self.function_code + + +class GetCommEventLogResponse(ModbusResponse): + ''' + The normal response contains a two-byte status word field, + a two-byte event count field, a two-byte message count field, + and a field containing 0-64 bytes of events. A byte count + field defines the total length of the data in these four field + ''' + function_code = 0x0c + _rtu_byte_count_pos = 2 + + def __init__(self, **kwargs): + ''' Initializes a new instance + + :param status: The status response to report + :param message_count: The current message count + :param event_count: The current event count + :param events: The collection of events to send + ''' + ModbusResponse.__init__(self, **kwargs) + self.status = kwargs.get('status', True) + self.message_count = kwargs.get('message_count', 0) + self.event_count = kwargs.get('event_count', 0) + self.events = kwargs.get('events', []) + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + if self.status: ready = ModbusStatus.Ready + else: ready = ModbusStatus.Waiting + packet = struct.pack('>B', 6 + len(self.events)) + packet += struct.pack('>H', ready) + packet += struct.pack('>HH', self.event_count, self.message_count) + packet += b''.join(struct.pack('>B', e) for e in self.events) + return packet + + def decode(self, data): + ''' Decodes a the response + + :param data: The packet data to decode + ''' + length = byte2int(data[0]) + status = struct.unpack('>H', data[1:3])[0] + self.status = (status == ModbusStatus.Ready) + self.event_count = struct.unpack('>H', data[3:5])[0] + self.message_count = struct.unpack('>H', data[5:7])[0] + + self.events = [] + for e in range(7, length + 1): + self.events.append(byte2int(data[e])) + + def __str__(self): + ''' Builds a representation of the response + + :returns: The string representation of the response + ''' + arguments = (self.function_code, self.status, self.message_count, self.event_count) + return "GetCommEventLogResponse(%d, %d, %d, %d)" % arguments + + +#---------------------------------------------------------------------------# +# TODO Make these only work on serial +#---------------------------------------------------------------------------# +class ReportSlaveIdRequest(ModbusRequest): + ''' + This function code is used to read the description of the type, the + current status, and other information specific to a remote device. + ''' + function_code = 0x11 + _rtu_frame_size = 4 + + def __init__(self, **kwargs): + ''' Initializes a new instance + ''' + ModbusRequest.__init__(self, **kwargs) + + def encode(self): + ''' Encodes the message + ''' + return b'' + + def decode(self, data): + ''' Decodes data part of the message. + + :param data: The incoming data + ''' + pass + + def execute(self, context=None): + ''' Run a report slave id request against the store + + :returns: The populated response + ''' + reportSlaveIdData = None + if context: + reportSlaveIdData = getattr(context, 'reportSlaveIdData', None) + if not reportSlaveIdData: + information = DeviceInformationFactory.get(_MCB) + identifier = "-".join(information.values()).encode() + identifier = identifier or b'Pymodbus' + reportSlaveIdData = identifier + return ReportSlaveIdResponse(reportSlaveIdData) + + def __str__(self): + ''' Builds a representation of the request + + :returns: The string representation of the request + ''' + return "ReportSlaveIdRequest(%d)" % self.function_code + + +class ReportSlaveIdResponse(ModbusResponse): + ''' + The format of a normal response is shown in the following example. + The data contents are specific to each type of device. + ''' + function_code = 0x11 + _rtu_byte_count_pos = 2 + + def __init__(self, identifier=b'\x00', status=True, **kwargs): + ''' Initializes a new instance + + :param identifier: The identifier of the slave + :param status: The status response to report + ''' + ModbusResponse.__init__(self, **kwargs) + self.identifier = identifier + self.status = status + self.byte_count = None + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + if self.status: + status = ModbusStatus.SlaveOn + else: + status = ModbusStatus.SlaveOff + length = len(self.identifier) + 1 + packet = int2byte(length) + packet += self.identifier # we assume it is already encoded + packet += int2byte(status) + return packet + + def decode(self, data): + ''' Decodes a the response + + Since the identifier is device dependent, we just return the + raw value that a user can decode to whatever it should be. + + :param data: The packet data to decode + ''' + self.byte_count = byte2int(data[0]) + self.identifier = data[1:self.byte_count + 1] + status = byte2int(data[-1]) + self.status = status == ModbusStatus.SlaveOn + + def __str__(self): + ''' Builds a representation of the response + + :returns: The string representation of the response + ''' + arguments = (self.function_code, self.identifier, self.status) + return "ReportSlaveIdResponse(%s, %s, %s)" % arguments + +#---------------------------------------------------------------------------# +# TODO Make these only work on serial +#---------------------------------------------------------------------------# +# report device identification 43, 14 + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "ReadExceptionStatusRequest", "ReadExceptionStatusResponse", + "GetCommEventCounterRequest", "GetCommEventCounterResponse", + "GetCommEventLogRequest", "GetCommEventLogResponse", + "ReportSlaveIdRequest", "ReportSlaveIdResponse", +] diff --git a/modbus/pymodbus/payload.py b/modbus/pymodbus/payload.py new file mode 100644 index 00000000..ee7da484 --- /dev/null +++ b/modbus/pymodbus/payload.py @@ -0,0 +1,509 @@ +""" +Modbus Payload Builders +------------------------ + +A collection of utilities for building and decoding +modbus messages payloads. + + +""" +from struct import pack, unpack +from pymodbus.interfaces import IPayloadBuilder +from pymodbus.constants import Endian +from pymodbus.utilities import pack_bitstring +from pymodbus.utilities import unpack_bitstring +from pymodbus.utilities import make_byte_string +from pymodbus.exceptions import ParameterException +from pymodbus.compat import unicode_string, IS_PYTHON3, PYTHON_VERSION +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +WC = { + "b": 1, + "h": 2, + "e": 2, + "i": 4, + "l": 4, + "q": 8, + "f": 4, + "d": 8 +} + + +class BinaryPayloadBuilder(IPayloadBuilder): + """ + A utility that helps build payload messages to be + written with the various modbus messages. It really is just + a simple wrapper around the struct module, however it saves + time looking up the format strings. What follows is a simple + example:: + + builder = BinaryPayloadBuilder(byteorder=Endian.Little) + builder.add_8bit_uint(1) + builder.add_16bit_uint(2) + payload = builder.build() + """ + + def __init__(self, payload=None, byteorder=Endian.Little, + wordorder=Endian.Big, repack=False): + """ Initialize a new instance of the payload builder + + :param payload: Raw binary payload data to initialize with + :param byteorder: The endianess of the bytes in the words + :param wordorder: The endianess of the word (when wordcount is >= 2) + :param repack: Repack the provided payload based on BO + """ + self._payload = payload or [] + self._byteorder = byteorder + self._wordorder = wordorder + self._repack = repack + + def _pack_words(self, fstring, value): + """ + Packs Words based on the word order and byte order + + # ---------------------------------------------- # + # pack in to network ordered value # + # unpack in to network ordered unsigned integer # + # Change Word order if little endian word order # + # Pack values back based on correct byte order # + # ---------------------------------------------- # + + :param value: Value to be packed + :return: + """ + value = pack("!{}".format(fstring), value) + wc = WC.get(fstring.lower())//2 + up = "!{}H".format(wc) + payload = unpack(up, value) + + if self._wordorder == Endian.Little: + payload = list(reversed(payload)) + + fstring = self._byteorder + "H" + payload = [pack(fstring, word) for word in payload] + payload = b''.join(payload) + + return payload + + def to_string(self): + """ Return the payload buffer as a string + + :returns: The payload buffer as a string + """ + return b''.join(self._payload) + + def __str__(self): + """ Return the payload buffer as a string + + :returns: The payload buffer as a string + """ + return self.to_string().decode('utf-8') + + def reset(self): + """ Reset the payload buffer + """ + self._payload = [] + + def to_registers(self): + """ Convert the payload buffer into a register + layout that can be used as a context block. + + :returns: The register layout to use as a block + """ + # fstring = self._byteorder+'H' + fstring = '!H' + payload = self.build() + if self._repack: + payload = [unpack(self._byteorder+"H", value)[0] for value in payload] + else: + payload = [unpack(fstring, value)[0] for value in payload] + _logger.debug(payload) + return payload + + def to_coils(self): + """Convert the payload buffer into a coil + layout that can be used as a context block. + + :returns: The coil layout to use as a block + """ + payload = self.to_registers() + coils = [bool(int(bit)) for reg + in payload for bit in format(reg, '016b')] + return coils + + def build(self): + """ Return the payload buffer as a list + + This list is two bytes per element and can + thus be treated as a list of registers. + + :returns: The payload buffer as a list + """ + string = self.to_string() + length = len(string) + string = string + (b'\x00' * (length % 2)) + return [string[i:i+2] for i in range(0, length, 2)] + + def add_bits(self, values): + """ Adds a collection of bits to be encoded + + If these are less than a multiple of eight, + they will be left padded with 0 bits to make + it so. + + :param value: The value to add to the buffer + """ + value = pack_bitstring(values) + self._payload.append(value) + + def add_8bit_uint(self, value): + """ Adds a 8 bit unsigned int to the buffer + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + 'B' + self._payload.append(pack(fstring, value)) + + def add_16bit_uint(self, value): + """ Adds a 16 bit unsigned int to the buffer + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + 'H' + self._payload.append(pack(fstring, value)) + + def add_32bit_uint(self, value): + """ Adds a 32 bit unsigned int to the buffer + + :param value: The value to add to the buffer + """ + fstring = 'I' + # fstring = self._byteorder + 'I' + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_64bit_uint(self, value): + """ Adds a 64 bit unsigned int to the buffer + + :param value: The value to add to the buffer + """ + fstring = 'Q' + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_8bit_int(self, value): + """ Adds a 8 bit signed int to the buffer + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + 'b' + self._payload.append(pack(fstring, value)) + + def add_16bit_int(self, value): + """ Adds a 16 bit signed int to the buffer + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + 'h' + self._payload.append(pack(fstring, value)) + + def add_32bit_int(self, value): + """ Adds a 32 bit signed int to the buffer + + :param value: The value to add to the buffer + """ + fstring = 'i' + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_64bit_int(self, value): + """ Adds a 64 bit signed int to the buffer + + :param value: The value to add to the buffer + """ + fstring = 'q' + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_16bit_float(self, value): + """ Adds a 16 bit float to the buffer + + :param value: The value to add to the buffer + """ + if IS_PYTHON3 and PYTHON_VERSION.minor >= 6: + fstring = 'e' + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + else: + _logger.warning("float16 only supported on python3.6 and above!!!") + + def add_32bit_float(self, value): + """ Adds a 32 bit float to the buffer + + :param value: The value to add to the buffer + """ + fstring = 'f' + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_64bit_float(self, value): + """ Adds a 64 bit float(double) to the buffer + + :param value: The value to add to the buffer + """ + fstring = 'd' + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_string(self, value): + """ Adds a string to the buffer + + :param value: The value to add to the buffer + """ + value = make_byte_string(value) + fstring = self._byteorder + str(len(value)) + 's' + self._payload.append(pack(fstring, value)) + + +class BinaryPayloadDecoder(object): + """ + A utility that helps decode payload messages from a modbus + response message. It really is just a simple wrapper around + the struct module, however it saves time looking up the format + strings. What follows is a simple example:: + + decoder = BinaryPayloadDecoder(payload) + first = decoder.decode_8bit_uint() + second = decoder.decode_16bit_uint() + """ + + def __init__(self, payload, byteorder=Endian.Little, wordorder=Endian.Big): + """ Initialize a new payload decoder + + :param payload: The payload to decode with + :param byteorder: The endianess of the payload + :param wordorder: The endianess of the word (when wordcount is >= 2) + """ + self._payload = payload + self._pointer = 0x00 + self._byteorder = byteorder + self._wordorder = wordorder + + @classmethod + def fromRegisters(klass, registers, byteorder=Endian.Little, + wordorder=Endian.Big): + """ Initialize a payload decoder with the result of + reading a collection of registers from a modbus device. + + The registers are treated as a list of 2 byte values. + We have to do this because of how the data has already + been decoded by the rest of the library. + + :param registers: The register results to initialize with + :param byteorder: The Byte order of each word + :param wordorder: The endianess of the word (when wordcount is >= 2) + :returns: An initialized PayloadDecoder + """ + _logger.debug(registers) + if isinstance(registers, list): # repack into flat binary + payload = b''.join(pack('!H', x) for x in registers) + return klass(payload, byteorder, wordorder) + raise ParameterException('Invalid collection of registers supplied') + + @classmethod + def bit_chunks(cls, coils, size=8): + chunks = [coils[i: i + size] for i in range(0, len(coils), size)] + return chunks + + @classmethod + def fromCoils(klass, coils, byteorder=Endian.Little, wordorder=Endian.Big): + """ Initialize a payload decoder with the result of + reading a collection of coils from a modbus device. + + The coils are treated as a list of bit(boolean) values. + + :param coils: The coil results to initialize with + :param byteorder: The endianess of the payload + :returns: An initialized PayloadDecoder + """ + if isinstance(coils, list): + payload = b'' + padding = len(coils) % 8 + if padding: # Pad zero's + extra = [False] * padding + coils = extra + coils + chunks = klass.bit_chunks(coils) + for chunk in chunks: + payload += pack_bitstring(chunk[::-1]) + return klass(payload, byteorder) + raise ParameterException('Invalid collection of coils supplied') + + def _unpack_words(self, fstring, handle): + """ + Un Packs Words based on the word order and byte order + + # ---------------------------------------------- # + # Unpack in to network ordered unsigned integer # + # Change Word order if little endian word order # + # Pack values back based on correct byte order # + # ---------------------------------------------- # + :param handle: Value to be unpacked + :return: + """ + handle = make_byte_string(handle) + wc = WC.get(fstring.lower()) // 2 + up = "!{}H".format(wc) + handle = unpack(up, handle) + if self._wordorder == Endian.Little: + handle = list(reversed(handle)) + + # Repack as unsigned Integer + pk = self._byteorder + 'H' + handle = [pack(pk, p) for p in handle] + _logger.debug(handle) + handle = b''.join(handle) + return handle + + def reset(self): + """ Reset the decoder pointer back to the start + """ + self._pointer = 0x00 + + def decode_8bit_uint(self): + """ Decodes a 8 bit unsigned int from the buffer + """ + self._pointer += 1 + fstring = self._byteorder + 'B' + handle = self._payload[self._pointer - 1:self._pointer] + handle = make_byte_string(handle) + return unpack(fstring, handle)[0] + + def decode_bits(self): + """ Decodes a byte worth of bits from the buffer + """ + self._pointer += 1 + # fstring = self._endian + 'B' + handle = self._payload[self._pointer - 1:self._pointer] + handle = make_byte_string(handle) + return unpack_bitstring(handle) + + def decode_16bit_uint(self): + """ Decodes a 16 bit unsigned int from the buffer + """ + self._pointer += 2 + fstring = self._byteorder + 'H' + handle = self._payload[self._pointer - 2:self._pointer] + handle = make_byte_string(handle) + return unpack(fstring, handle)[0] + + def decode_32bit_uint(self): + """ Decodes a 32 bit unsigned int from the buffer + """ + self._pointer += 4 + fstring = 'I' + # fstring = 'I' + handle = self._payload[self._pointer - 4:self._pointer] + handle = self._unpack_words(fstring, handle) + return unpack("!"+fstring, handle)[0] + + def decode_64bit_uint(self): + """ Decodes a 64 bit unsigned int from the buffer + """ + self._pointer += 8 + fstring = 'Q' + handle = self._payload[self._pointer - 8:self._pointer] + handle = self._unpack_words(fstring, handle) + return unpack("!"+fstring, handle)[0] + + def decode_8bit_int(self): + """ Decodes a 8 bit signed int from the buffer + """ + self._pointer += 1 + fstring = self._byteorder + 'b' + handle = self._payload[self._pointer - 1:self._pointer] + handle = make_byte_string(handle) + return unpack(fstring, handle)[0] + + def decode_16bit_int(self): + """ Decodes a 16 bit signed int from the buffer + """ + self._pointer += 2 + fstring = self._byteorder + 'h' + handle = self._payload[self._pointer - 2:self._pointer] + handle = make_byte_string(handle) + return unpack(fstring, handle)[0] + + def decode_32bit_int(self): + """ Decodes a 32 bit signed int from the buffer + """ + self._pointer += 4 + fstring = 'i' + handle = self._payload[self._pointer - 4:self._pointer] + handle = self._unpack_words(fstring, handle) + return unpack("!"+fstring, handle)[0] + + def decode_64bit_int(self): + """ Decodes a 64 bit signed int from the buffer + """ + self._pointer += 8 + fstring = 'q' + handle = self._payload[self._pointer - 8:self._pointer] + handle = self._unpack_words(fstring, handle) + return unpack("!"+fstring, handle)[0] + + def decode_16bit_float(self): + """ Decodes a 16 bit float from the buffer + """ + if IS_PYTHON3 and PYTHON_VERSION.minor >= 6: + self._pointer += 2 + fstring = 'e' + handle = self._payload[self._pointer - 2:self._pointer] + handle = self._unpack_words(fstring, handle) + return unpack("!"+fstring, handle)[0] + else: + _logger.warning("float16 only supported on python3.6 and above!!!") + + def decode_32bit_float(self): + """ Decodes a 32 bit float from the buffer + """ + self._pointer += 4 + fstring = 'f' + handle = self._payload[self._pointer - 4:self._pointer] + handle = self._unpack_words(fstring, handle) + return unpack("!"+fstring, handle)[0] + + def decode_64bit_float(self): + """ Decodes a 64 bit float(double) from the buffer + """ + self._pointer += 8 + fstring = 'd' + handle = self._payload[self._pointer - 8:self._pointer] + handle = self._unpack_words(fstring, handle) + return unpack("!"+fstring, handle)[0] + + def decode_string(self, size=1): + """ Decodes a string from the buffer + + :param size: The size of the string to decode + """ + self._pointer += size + s = self._payload[self._pointer - size:self._pointer] + return s + + def skip_bytes(self, nbytes): + """ Skip n bytes in the buffer + + :param nbytes: The number of bytes to skip + """ + self._pointer += nbytes + return None + +#---------------------------------------------------------------------------# +# Exported Identifiers +#---------------------------------------------------------------------------# +__all__ = ["BinaryPayloadBuilder", "BinaryPayloadDecoder"] diff --git a/modbus/pymodbus/pdu.py b/modbus/pymodbus/pdu.py new file mode 100644 index 00000000..2c7c55d8 --- /dev/null +++ b/modbus/pymodbus/pdu.py @@ -0,0 +1,247 @@ +""" +Contains base classes for modbus request/response/error packets +""" +from pymodbus.interfaces import Singleton +from pymodbus.exceptions import NotImplementedException +from pymodbus.constants import Defaults +from pymodbus.utilities import rtuFrameSize +from pymodbus.compat import iteritems, int2byte, byte2int + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Base PDU's +# --------------------------------------------------------------------------- # +class ModbusPDU(object): + """ + Base class for all Modbus messages + + .. attribute:: transaction_id + + This value is used to uniquely identify a request + response pair. It can be implemented as a simple counter + + .. attribute:: protocol_id + + This is a constant set at 0 to indicate Modbus. It is + put here for ease of expansion. + + .. attribute:: unit_id + + This is used to route the request to the correct child. In + the TCP modbus, it is used for routing (or not used at all. However, + for the serial versions, it is used to specify which child to perform + the requests against. The value 0x00 represents the broadcast address + (also 0xff). + + .. attribute:: check + + This is used for LRC/CRC in the serial modbus protocols + + .. attribute:: skip_encode + + This is used when the message payload has already been encoded. + Generally this will occur when the PayloadBuilder is being used + to create a complicated message. By setting this to True, the + request will pass the currently encoded message through instead + of encoding it again. + """ + + def __init__(self, **kwargs): + """ Initializes the base data for a modbus request """ + self.transaction_id = kwargs.get('transaction', Defaults.TransactionId) + self.protocol_id = kwargs.get('protocol', Defaults.ProtocolId) + self.unit_id = kwargs.get('unit', Defaults.UnitId) + self.skip_encode = kwargs.get('skip_encode', False) + self.check = 0x0000 + + def encode(self): + """ Encodes the message + + :raises: A not implemented exception + """ + raise NotImplementedException() + + def decode(self, data): + """ Decodes data part of the message. + + :param data: is a string object + :raises: A not implemented exception + """ + raise NotImplementedException() + + @classmethod + def calculateRtuFrameSize(cls, buffer): + """ Calculates the size of a PDU. + + :param buffer: A buffer containing the data that have been received. + :returns: The number of bytes in the PDU. + """ + if hasattr(cls, '_rtu_frame_size'): + return cls._rtu_frame_size + elif hasattr(cls, '_rtu_byte_count_pos'): + return rtuFrameSize(buffer, cls._rtu_byte_count_pos) + else: raise NotImplementedException( + "Cannot determine RTU frame size for %s" % cls.__name__) + + +class ModbusRequest(ModbusPDU): + """ Base class for a modbus request PDU """ + + def __init__(self, **kwargs): + """ Proxy to the lower level initializer """ + ModbusPDU.__init__(self, **kwargs) + + def doException(self, exception): + """ Builds an error response based on the function + + :param exception: The exception to return + :raises: An exception response + """ + exc = ExceptionResponse(self.function_code, exception) + _logger.error(exc) + return exc + + +class ModbusResponse(ModbusPDU): + """ Base class for a modbus response PDU + + .. attribute:: should_respond + + A flag that indicates if this response returns a result back + to the client issuing the request + + .. attribute:: _rtu_frame_size + + Indicates the size of the modbus rtu response used for + calculating how much to read. + """ + + should_respond = True + + def __init__(self, **kwargs): + """ Proxy to the lower level initializer """ + ModbusPDU.__init__(self, **kwargs) + + def isError(self): + """Checks if the error is a success or failure""" + return self.function_code > 0x80 + + +# --------------------------------------------------------------------------- # +# Exception PDU's +# --------------------------------------------------------------------------- # +class ModbusExceptions(Singleton): + """ + An enumeration of the valid modbus exceptions + """ + IllegalFunction = 0x01 + IllegalAddress = 0x02 + IllegalValue = 0x03 + SlaveFailure = 0x04 + Acknowledge = 0x05 + SlaveBusy = 0x06 + MemoryParityError = 0x08 + GatewayPathUnavailable = 0x0A + GatewayNoResponse = 0x0B + + @classmethod + def decode(cls, code): + """ Given an error code, translate it to a + string error name. + + :param code: The code number to translate + """ + values = dict((v, k) for k, v in iteritems(cls.__dict__) + if not k.startswith('__') and not callable(v)) + return values.get(code, None) + + +class ExceptionResponse(ModbusResponse): + """ Base class for a modbus exception PDU """ + ExceptionOffset = 0x80 + _rtu_frame_size = 5 + + def __init__(self, function_code, exception_code=None, **kwargs): + """ Initializes the modbus exception response + + :param function_code: The function to build an exception response for + :param exception_code: The specific modbus exception to return + """ + ModbusResponse.__init__(self, **kwargs) + self.original_code = function_code + self.function_code = function_code | self.ExceptionOffset + self.exception_code = exception_code + + def encode(self): + """ Encodes a modbus exception response + + :returns: The encoded exception packet + """ + return int2byte(self.exception_code) + + def decode(self, data): + """ Decodes a modbus exception response + + :param data: The packet data to decode + """ + self.exception_code = byte2int(data[0]) + + def __str__(self): + """ Builds a representation of an exception response + + :returns: The string representation of an exception response + """ + message = ModbusExceptions.decode(self.exception_code) + parameters = (self.function_code, self.original_code, message) + return "Exception Response(%d, %d, %s)" % parameters + + +class IllegalFunctionRequest(ModbusRequest): + """ + Defines the Modbus slave exception type 'Illegal Function' + This exception code is returned if the slave:: + + - does not implement the function code **or** + - is not in a state that allows it to process the function + """ + ErrorCode = 1 + + def __init__(self, function_code, **kwargs): + """ Initializes a IllegalFunctionRequest + + :param function_code: The function we are erroring on + """ + ModbusRequest.__init__(self, **kwargs) + self.function_code = function_code + + def decode(self, data): + """ This is here so this failure will run correctly + + :param data: Not used + """ + pass + + def execute(self, context): + """ Builds an illegal function request error response + + :param context: The current context for the message + :returns: The error response packet + """ + return ExceptionResponse(self.function_code, self.ErrorCode) + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + + +__all__ = [ + 'ModbusRequest', 'ModbusResponse', 'ModbusExceptions', + 'ExceptionResponse', 'IllegalFunctionRequest', +] + diff --git a/modbus/pymodbus/register_read_message.py b/modbus/pymodbus/register_read_message.py new file mode 100644 index 00000000..1c406ead --- /dev/null +++ b/modbus/pymodbus/register_read_message.py @@ -0,0 +1,359 @@ +''' +Register Reading Request/Response +--------------------------------- +''' +import struct +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.compat import int2byte, byte2int + + +class ReadRegistersRequestBase(ModbusRequest): + ''' + Base class for reading a modbus register + ''' + _rtu_frame_size = 8 + + def __init__(self, address, count, **kwargs): + ''' Initializes a new instance + + :param address: The address to start the read from + :param count: The number of registers to read + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + self.count = count + + def encode(self): + ''' Encodes the request packet + + :return: The encoded packet + ''' + return struct.pack('>HH', self.address, self.count) + + def decode(self, data): + ''' Decode a register request packet + + :param data: The request to decode + ''' + self.address, self.count = struct.unpack('>HH', data) + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Byte Count(1 byte) + 2 * Quantity of Coils (n Bytes) + :return: + """ + return 1 + 1 + 2 * self.count + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "ReadRegisterRequest (%d,%d)" % (self.address, self.count) + + +class ReadRegistersResponseBase(ModbusResponse): + ''' + Base class for responsing to a modbus register read + ''' + + _rtu_byte_count_pos = 2 + + def __init__(self, values, **kwargs): + ''' Initializes a new instance + + :param values: The values to write to + ''' + ModbusResponse.__init__(self, **kwargs) + self.registers = values or [] + + def encode(self): + ''' Encodes the response packet + + :returns: The encoded packet + ''' + result = int2byte(len(self.registers) * 2) + for register in self.registers: + result += struct.pack('>H', register) + return result + + def decode(self, data): + ''' Decode a register response packet + + :param data: The request to decode + ''' + byte_count = byte2int(data[0]) + self.registers = [] + for i in range(1, byte_count + 1, 2): + self.registers.append(struct.unpack('>H', data[i:i + 2])[0]) + + def getRegister(self, index): + ''' Get the requested register + + :param index: The indexed register to retrieve + :returns: The request register + ''' + return self.registers[index] + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "%s (%d)" % (self.__class__.__name__, len(self.registers)) + + +class ReadHoldingRegistersRequest(ReadRegistersRequestBase): + ''' + This function code is used to read the contents of a contiguous block + of holding registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore registers numbered + 1-16 are addressed as 0-15. + ''' + function_code = 3 + + def __init__(self, address=None, count=None, **kwargs): + ''' Initializes a new instance of the request + + :param address: The starting address to read from + :param count: The number of registers to read from address + ''' + ReadRegistersRequestBase.__init__(self, address, count, **kwargs) + + def execute(self, context): + ''' Run a read holding request against a datastore + + :param context: The datastore to request from + :returns: An initialized response, exception message otherwise + ''' + if not (1 <= self.count <= 0x7d): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = context.getValues(self.function_code, self.address, self.count) + return ReadHoldingRegistersResponse(values) + + +class ReadHoldingRegistersResponse(ReadRegistersResponseBase): + ''' + This function code is used to read the contents of a contiguous block + of holding registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore registers numbered + 1-16 are addressed as 0-15. + ''' + function_code = 3 + + def __init__(self, values=None, **kwargs): + ''' Initializes a new response instance + + :param values: The resulting register values + ''' + ReadRegistersResponseBase.__init__(self, values, **kwargs) + + +class ReadInputRegistersRequest(ReadRegistersRequestBase): + ''' + This function code is used to read from 1 to approx. 125 contiguous + input registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore input registers + numbered 1-16 are addressed as 0-15. + ''' + function_code = 4 + + def __init__(self, address=None, count=None, **kwargs): + ''' Initializes a new instance of the request + + :param address: The starting address to read from + :param count: The number of registers to read from address + ''' + ReadRegistersRequestBase.__init__(self, address, count, **kwargs) + + def execute(self, context): + ''' Run a read input request against a datastore + + :param context: The datastore to request from + :returns: An initialized response, exception message otherwise + ''' + if not (1 <= self.count <= 0x7d): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = context.getValues(self.function_code, self.address, self.count) + return ReadInputRegistersResponse(values) + + +class ReadInputRegistersResponse(ReadRegistersResponseBase): + ''' + This function code is used to read from 1 to approx. 125 contiguous + input registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore input registers + numbered 1-16 are addressed as 0-15. + ''' + function_code = 4 + + def __init__(self, values=None, **kwargs): + ''' Initializes a new response instance + + :param values: The resulting register values + ''' + ReadRegistersResponseBase.__init__(self, values, **kwargs) + + +class ReadWriteMultipleRegistersRequest(ModbusRequest): + ''' + This function code performs a combination of one read operation and one + write operation in a single MODBUS transaction. The write + operation is performed before the read. + + Holding registers are addressed starting at zero. Therefore holding + registers 1-16 are addressed in the PDU as 0-15. + + The request specifies the starting address and number of holding + registers to be read as well as the starting address, number of holding + registers, and the data to be written. The byte count specifies the + number of bytes to follow in the write data field." + ''' + function_code = 23 + _rtu_byte_count_pos = 10 + + def __init__(self, **kwargs): + ''' Initializes a new request message + + :param read_address: The address to start reading from + :param read_count: The number of registers to read from address + :param write_address: The address to start writing to + :param write_registers: The registers to write to the specified address + ''' + ModbusRequest.__init__(self, **kwargs) + self.read_address = kwargs.get('read_address', 0x00) + self.read_count = kwargs.get('read_count', 0) + self.write_address = kwargs.get('write_address', 0x00) + self.write_registers = kwargs.get('write_registers', None) + if not hasattr(self.write_registers, '__iter__'): + self.write_registers = [self.write_registers] + self.write_count = len(self.write_registers) + self.write_byte_count = self.write_count * 2 + + def encode(self): + ''' Encodes the request packet + + :returns: The encoded packet + ''' + result = struct.pack('>HHHHB', + self.read_address, self.read_count, \ + self.write_address, self.write_count, self.write_byte_count) + for register in self.write_registers: + result += struct.pack('>H', register) + return result + + def decode(self, data): + ''' Decode the register request packet + + :param data: The request to decode + ''' + self.read_address, self.read_count, \ + self.write_address, self.write_count, \ + self.write_byte_count = struct.unpack('>HHHHB', data[:9]) + self.write_registers = [] + for i in range(9, self.write_byte_count + 9, 2): + register = struct.unpack('>H', data[i:i + 2])[0] + self.write_registers.append(register) + + def execute(self, context): + ''' Run a write single register request against a datastore + + :param context: The datastore to request from + :returns: An initialized response, exception message otherwise + ''' + if not (1 <= self.read_count <= 0x07d): + return self.doException(merror.IllegalValue) + if not (1 <= self.write_count <= 0x079): + return self.doException(merror.IllegalValue) + if (self.write_byte_count != self.write_count * 2): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.write_address, + self.write_count): + return self.doException(merror.IllegalAddress) + if not context.validate(self.function_code, self.read_address, + self.read_count): + return self.doException(merror.IllegalAddress) + context.setValues(self.function_code, self.write_address, + self.write_registers) + registers = context.getValues(self.function_code, self.read_address, + self.read_count) + return ReadWriteMultipleRegistersResponse(registers) + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Byte Count(1 byte) + 2 * Quantity of Coils (n Bytes) + :return: + """ + return 1 + 1 + 2 * self.read_count + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + params = (self.read_address, self.read_count, self.write_address, + self.write_count) + return "ReadWriteNRegisterRequest R(%d,%d) W(%d,%d)" % params + + +class ReadWriteMultipleRegistersResponse(ModbusResponse): + ''' + The normal response contains the data from the group of registers that + were read. The byte count field specifies the quantity of bytes to + follow in the read data field. + ''' + function_code = 23 + _rtu_byte_count_pos = 2 + + def __init__(self, values=None, **kwargs): + ''' Initializes a new instance + + :param values: The register values to write + ''' + ModbusResponse.__init__(self, **kwargs) + self.registers = values or [] + + def encode(self): + ''' Encodes the response packet + + :returns: The encoded packet + ''' + result = int2byte(len(self.registers) * 2) + for register in self.registers: + result += struct.pack('>H', register) + return result + + def decode(self, data): + ''' Decode the register response packet + + :param data: The response to decode + ''' + bytecount = byte2int(data[0]) + for i in range(1, bytecount, 2): + self.registers.append(struct.unpack('>H', data[i:i + 2])[0]) + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "ReadWriteNRegisterResponse (%d)" % len(self.registers) + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "ReadHoldingRegistersRequest", "ReadHoldingRegistersResponse", + "ReadInputRegistersRequest", "ReadInputRegistersResponse", + "ReadWriteMultipleRegistersRequest", "ReadWriteMultipleRegistersResponse", +] diff --git a/modbus/pymodbus/register_write_message.py b/modbus/pymodbus/register_write_message.py new file mode 100644 index 00000000..3a128aba --- /dev/null +++ b/modbus/pymodbus/register_write_message.py @@ -0,0 +1,354 @@ +''' +Register Writing Request/Response Messages +------------------------------------------- +''' +import struct +from pymodbus.pdu import ModbusRequest +from pymodbus.pdu import ModbusResponse +from pymodbus.pdu import ModbusExceptions as merror + + +class WriteSingleRegisterRequest(ModbusRequest): + ''' + This function code is used to write a single holding register in a + remote device. + + The Request PDU specifies the address of the register to + be written. Registers are addressed starting at zero. Therefore register + numbered 1 is addressed as 0. + ''' + function_code = 6 + _rtu_frame_size = 8 + + def __init__(self, address=None, value=None, **kwargs): + ''' Initializes a new instance + + :param address: The address to start writing add + :param value: The values to write + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + self.value = value + + def encode(self): + ''' Encode a write single register packet packet request + + :returns: The encoded packet + ''' + packet = struct.pack('>H', self.address) + if self.skip_encode: + packet += self.value + else: + packet += struct.pack('>H', self.value) + return packet + + def decode(self, data): + ''' Decode a write single register packet packet request + + :param data: The request to decode + ''' + self.address, self.value = struct.unpack('>HH', data) + + def execute(self, context): + ''' Run a write single register request against a datastore + + :param context: The datastore to request from + :returns: An initialized response, exception message otherwise + ''' + if not (0 <= self.value <= 0xffff): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, 1): + return self.doException(merror.IllegalAddress) + + context.setValues(self.function_code, self.address, [self.value]) + values = context.getValues(self.function_code, self.address, 1) + return WriteSingleRegisterResponse(self.address, values[0]) + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Register Address(2 byte) + Register Value (2 bytes) + :return: + """ + return 1 + 2 + 2 + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + return "WriteRegisterRequest %d" % self.address + + +class WriteSingleRegisterResponse(ModbusResponse): + ''' + The normal response is an echo of the request, returned after the + register contents have been written. + ''' + function_code = 6 + _rtu_frame_size = 8 + + def __init__(self, address=None, value=None, **kwargs): + ''' Initializes a new instance + + :param address: The address to start writing add + :param value: The values to write + ''' + ModbusResponse.__init__(self, **kwargs) + self.address = address + self.value = value + + def encode(self): + ''' Encode a write single register packet packet request + + :returns: The encoded packet + ''' + return struct.pack('>HH', self.address, self.value) + + def decode(self, data): + ''' Decode a write single register packet packet request + + :param data: The request to decode + ''' + self.address, self.value = struct.unpack('>HH', data) + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + params = (self.address, self.value) + return "WriteRegisterResponse %d => %d" % params + + +#---------------------------------------------------------------------------# +# Write Multiple Registers +#---------------------------------------------------------------------------# +class WriteMultipleRegistersRequest(ModbusRequest): + ''' + This function code is used to write a block of contiguous registers (1 + to approx. 120 registers) in a remote device. + + The requested written values are specified in the request data field. + Data is packed as two bytes per register. + ''' + function_code = 16 + _rtu_byte_count_pos = 6 + _pdu_length = 5 #func + adress1 + adress2 + outputQuant1 + outputQuant2 + + def __init__(self, address=None, values=None, **kwargs): + ''' Initializes a new instance + + :param address: The address to start writing to + :param values: The values to write + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + if values is None: + values = [] + elif not hasattr(values, '__iter__'): + values = [values] + self.values = values + self.count = len(self.values) + self.byte_count = self.count * 2 + + def encode(self): + ''' Encode a write single register packet packet request + + :returns: The encoded packet + ''' + packet = struct.pack('>HHB', self.address, self.count, self.byte_count) + if self.skip_encode: + return packet + b''.join(self.values) + + for value in self.values: + packet += struct.pack('>H', value) + + return packet + + def decode(self, data): + ''' Decode a write single register packet packet request + + :param data: The request to decode + ''' + self.address, self.count, \ + self.byte_count = struct.unpack('>HHB', data[:5]) + self.values = [] # reset + for idx in range(5, (self.count * 2) + 5, 2): + self.values.append(struct.unpack('>H', data[idx:idx + 2])[0]) + + def execute(self, context): + ''' Run a write single register request against a datastore + + :param context: The datastore to request from + :returns: An initialized response, exception message otherwise + ''' + if not (1 <= self.count <= 0x07b): + return self.doException(merror.IllegalValue) + if (self.byte_count != self.count * 2): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + + context.setValues(self.function_code, self.address, self.values) + return WriteMultipleRegistersResponse(self.address, self.count) + + def get_response_pdu_size(self): + """ + Func_code (1 byte) + Starting Address (2 byte) + Quantity of Reggisters (2 Bytes) + :return: + """ + return 1 + 2 + 2 + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + params = (self.address, self.count) + return "WriteMultipleRegisterRequest %d => %d" % params + + +class WriteMultipleRegistersResponse(ModbusResponse): + ''' + "The normal response returns the function code, starting address, and + quantity of registers written. + ''' + function_code = 16 + _rtu_frame_size = 8 + + def __init__(self, address=None, count=None, **kwargs): + ''' Initializes a new instance + + :param address: The address to start writing to + :param count: The number of registers to write to + ''' + ModbusResponse.__init__(self, **kwargs) + self.address = address + self.count = count + + def encode(self): + ''' Encode a write single register packet packet request + + :returns: The encoded packet + ''' + return struct.pack('>HH', self.address, self.count) + + def decode(self, data): + ''' Decode a write single register packet packet request + + :param data: The request to decode + ''' + self.address, self.count = struct.unpack('>HH', data) + + def __str__(self): + ''' Returns a string representation of the instance + + :returns: A string representation of the instance + ''' + params = (self.address, self.count) + return "WriteMultipleRegisterResponse (%d,%d)" % params + +class MaskWriteRegisterRequest(ModbusRequest): + ''' + This function code is used to modify the contents of a specified holding + register using a combination of an AND mask, an OR mask, and the + register's current contents. The function can be used to set or clear + individual bits in the register. + ''' + function_code = 0x16 + _rtu_frame_size = 10 + + def __init__(self, address=0x0000, and_mask=0xffff, or_mask=0x0000, + **kwargs): + ''' Initializes a new instance + + :param address: The mask pointer address (0x0000 to 0xffff) + :param and_mask: The and bitmask to apply to the register address + :param or_mask: The or bitmask to apply to the register address + ''' + ModbusRequest.__init__(self, **kwargs) + self.address = address + self.and_mask = and_mask + self.or_mask = or_mask + + def encode(self): + ''' Encodes the request packet + + :returns: The byte encoded packet + ''' + return struct.pack('>HHH', self.address, self.and_mask, + self.or_mask) + + def decode(self, data): + ''' Decodes the incoming request + + :param data: The data to decode into the address + ''' + self.address, self.and_mask, self.or_mask = struct.unpack('>HHH', + data) + + def execute(self, context): + ''' Run a mask write register request against the store + + :param context: The datastore to request from + :returns: The populated response + ''' + if not (0x0000 <= self.and_mask <= 0xffff): + return self.doException(merror.IllegalValue) + if not (0x0000 <= self.or_mask <= 0xffff): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, 1): + return self.doException(merror.IllegalAddress) + values = context.getValues(self.function_code, self.address, 1)[0] + values = ((values & self.and_mask) | self.or_mask) + context.setValues(self.function_code, self.address, [values]) + return MaskWriteRegisterResponse(self.address, self.and_mask, + self.or_mask) + + +class MaskWriteRegisterResponse(ModbusResponse): + ''' + The normal response is an echo of the request. The response is returned + after the register has been written. + ''' + function_code = 0x16 + _rtu_frame_size = 10 + + def __init__(self, address=0x0000, and_mask=0xffff, or_mask=0x0000, + **kwargs): + ''' Initializes a new instance + + :param address: The mask pointer address (0x0000 to 0xffff) + :param and_mask: The and bitmask applied to the register address + :param or_mask: The or bitmask applied to the register address + ''' + ModbusResponse.__init__(self, **kwargs) + self.address = address + self.and_mask = and_mask + self.or_mask = or_mask + + def encode(self): + ''' Encodes the response + + :returns: The byte encoded message + ''' + return struct.pack('>HHH', self.address, self.and_mask, + self.or_mask) + + def decode(self, data): + ''' Decodes a the response + + :param data: The packet data to decode + ''' + self.address, self.and_mask, self.or_mask = struct.unpack('>HHH', + data) + + +#---------------------------------------------------------------------------# +# Exported symbols +#---------------------------------------------------------------------------# +__all__ = [ + "WriteSingleRegisterRequest", "WriteSingleRegisterResponse", + "WriteMultipleRegistersRequest", "WriteMultipleRegistersResponse", + "MaskWriteRegisterRequest", "MaskWriteRegisterResponse" +] diff --git a/modbus/pymodbus/repl/README.md b/modbus/pymodbus/repl/README.md new file mode 100644 index 00000000..b4f192f4 --- /dev/null +++ b/modbus/pymodbus/repl/README.md @@ -0,0 +1,315 @@ +# Pymodbus REPL + +## Dependencies + +Depends on [prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/en/stable/index.html) and [click](http://click.pocoo.org/6/quickstart/) + +Install dependencies +``` +$ pip install click prompt_toolkit --upgrade +``` + +Or +Install pymodbus with repl support +``` +$ pip install pymodbus[repl] --upgrade +``` + +## Usage Instructions +RTU and TCP are supported as of now + +``` +✗ pymodbus.console --help +Usage: pymodbus.console [OPTIONS] COMMAND [ARGS]... + +Options: + --version Show the version and exit. + --verbose Verbose logs + --broadcast-support Support broadcast messages + --help Show this message and exit. + +Commands: + serial + tcp + + +``` +TCP Options + +``` +✗ pymodbus.console tcp --help +Usage: pymodbus.console tcp [OPTIONS] + +Options: + --host TEXT Modbus TCP IP + --port INTEGER Modbus TCP port + --framer TEXT Override the default packet framer tcp|rtu + --help Show this message and exit. + +``` + +SERIAL Options +``` +✗ pymodbus.console serial --help +Usage: pymodbus.console serial [OPTIONS] + +Options: + --method TEXT Modbus Serial Mode (rtu/ascii) + --port TEXT Modbus RTU port + --baudrate INTEGER Modbus RTU serial baudrate to use. Defaults to 9600 + --bytesize [5|6|7|8] Modbus RTU serial Number of data bits. Possible + values: FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS. + Defaults to 8 + + --parity [N|E|O|M|S] Modbus RTU serial parity. Enable parity checking. + Possible values: PARITY_NONE, PARITY_EVEN, PARITY_ODD + PARITY_MARK, PARITY_SPACE. Default to 'N' + + --stopbits [1|1.5|2] Modbus RTU serial stop bits. Number of stop bits. + Possible values: STOPBITS_ONE, + STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO. Default to '1' + + --xonxoff INTEGER Modbus RTU serial xonxoff. Enable software flow + control.Defaults to 0 + + --rtscts INTEGER Modbus RTU serial rtscts. Enable hardware (RTS/CTS) + flow control. Defaults to 0 + + --dsrdtr INTEGER Modbus RTU serial dsrdtr. Enable hardware (DSR/DTR) + flow control. Defaults to 0 + + --timeout FLOAT Modbus RTU serial read timeout. Defaults to 0.025 sec + --write-timeout FLOAT Modbus RTU serial write timeout. Defaults to 2 sec + --help Show this message and exit. +``` + +To view all available commands type `help` + +TCP +``` +$ pymodbus.console tcp --host 192.168.128.126 --port 5020 + +> help +Available commands: +client.change_ascii_input_delimiter Diagnostic sub command, Change message delimiter for future requests. +client.clear_counters Diagnostic sub command, Clear all counters and diag registers. +client.clear_overrun_count Diagnostic sub command, Clear over run counter. +client.close Closes the underlying socket connection +client.connect Connect to the modbus tcp server +client.debug_enabled Returns a boolean indicating if debug is enabled. +client.force_listen_only_mode Diagnostic sub command, Forces the addressed remote device to its Listen Only Mode. +client.get_clear_modbus_plus Diagnostic sub command, Get or clear stats of remote modbus plus device. +client.get_com_event_counter Read status word and an event count from the remote device's communication event counter. +client.get_com_event_log Read status word, event count, message count, and a field of event bytes from the remote device. +client.host Read Only! +client.idle_time Bus Idle Time to initiate next transaction +client.is_socket_open Check whether the underlying socket/serial is open or not. +client.last_frame_end Read Only! +client.mask_write_register Mask content of holding register at `address` with `and_mask` and `or_mask`. +client.port Read Only! +client.read_coils Reads `count` coils from a given slave starting at `address`. +client.read_device_information Read the identification and additional information of remote slave. +client.read_discrete_inputs Reads `count` number of discrete inputs starting at offset `address`. +client.read_exception_status Read the contents of eight Exception Status outputs in a remote device. +client.read_holding_registers Read `count` number of holding registers starting at `address`. +client.read_input_registers Read `count` number of input registers starting at `address`. +client.readwrite_registers Read `read_count` number of holding registers starting at `read_address` and write `write_registers` starting at `write_address`. +client.report_slave_id Report information about remote slave ID. +client.restart_comm_option Diagnostic sub command, initialize and restart remote devices serial interface and clear all of its communications event counters . +client.return_bus_com_error_count Diagnostic sub command, Return count of CRC errors received by remote slave. +client.return_bus_exception_error_count Diagnostic sub command, Return count of Modbus exceptions returned by remote slave. +client.return_bus_message_count Diagnostic sub command, Return count of message detected on bus by remote slave. +client.return_diagnostic_register Diagnostic sub command, Read 16-bit diagnostic register. +client.return_iop_overrun_count Diagnostic sub command, Return count of iop overrun errors by remote slave. +client.return_query_data Diagnostic sub command , Loop back data sent in response. +client.return_slave_bus_char_overrun_count Diagnostic sub command, Return count of messages not handled by remote slave due to character overrun condition. +client.return_slave_busy_count Diagnostic sub command, Return count of server busy exceptions sent by remote slave. +client.return_slave_message_count Diagnostic sub command, Return count of messages addressed to remote slave. +client.return_slave_no_ack_count Diagnostic sub command, Return count of NO ACK exceptions sent by remote slave. +client.return_slave_no_response_count Diagnostic sub command, Return count of No responses by remote slave. +client.silent_interval Read Only! +client.state Read Only! +client.timeout Read Only! +client.write_coil Write `value` to coil at `address`. +client.write_coils Write `value` to coil at `address`. +client.write_register Write `value` to register at `address`. +client.write_registers Write list of `values` to registers starting at `address`. +``` + +SERIAL +``` +$ pymodbus.console serial --port /dev/ttyUSB0 --baudrate 19200 --timeout 2 +> help +Available commands: +client.baudrate Read Only! +client.bytesize Read Only! +client.change_ascii_input_delimiter Diagnostic sub command, Change message delimiter for future requests. +client.clear_counters Diagnostic sub command, Clear all counters and diag registers. +client.clear_overrun_count Diagnostic sub command, Clear over run counter. +client.close Closes the underlying socket connection +client.connect Connect to the modbus serial server +client.debug_enabled Returns a boolean indicating if debug is enabled. +client.force_listen_only_mode Diagnostic sub command, Forces the addressed remote device to its Listen Only Mode. +client.get_baudrate Serial Port baudrate. +client.get_bytesize Number of data bits. +client.get_clear_modbus_plus Diagnostic sub command, Get or clear stats of remote modbus plus device. +client.get_com_event_counter Read status word and an event count from the remote device's communication event counter. +client.get_com_event_log Read status word, event count, message count, and a field of event bytes from the remote device. +client.get_parity Enable Parity Checking. +client.get_port Serial Port. +client.get_serial_settings Gets Current Serial port settings. +client.get_stopbits Number of stop bits. +client.get_timeout Serial Port Read timeout. +client.idle_time Bus Idle Time to initiate next transaction +client.inter_char_timeout Read Only! +client.is_socket_open c l i e n t . i s s o c k e t o p e n +client.mask_write_register Mask content of holding register at `address` with `and_mask` and `or_mask`. +client.method Read Only! +client.parity Read Only! +client.port Read Only! +client.read_coils Reads `count` coils from a given slave starting at `address`. +client.read_device_information Read the identification and additional information of remote slave. +client.read_discrete_inputs Reads `count` number of discrete inputs starting at offset `address`. +client.read_exception_status Read the contents of eight Exception Status outputs in a remote device. +client.read_holding_registers Read `count` number of holding registers starting at `address`. +client.read_input_registers Read `count` number of input registers starting at `address`. +client.readwrite_registers Read `read_count` number of holding registers starting at `read_address` and write `write_registers` starting at `write_address`. +client.report_slave_id Report information about remote slave ID. +client.restart_comm_option Diagnostic sub command, initialize and restart remote devices serial interface and clear all of its communications event counters . +client.return_bus_com_error_count Diagnostic sub command, Return count of CRC errors received by remote slave. +client.return_bus_exception_error_count Diagnostic sub command, Return count of Modbus exceptions returned by remote slave. +client.return_bus_message_count Diagnostic sub command, Return count of message detected on bus by remote slave. +client.return_diagnostic_register Diagnostic sub command, Read 16-bit diagnostic register. +client.return_iop_overrun_count Diagnostic sub command, Return count of iop overrun errors by remote slave. +client.return_query_data Diagnostic sub command , Loop back data sent in response. +client.return_slave_bus_char_overrun_count Diagnostic sub command, Return count of messages not handled by remote slave due to character overrun condition. +client.return_slave_busy_count Diagnostic sub command, Return count of server busy exceptions sent by remote slave. +client.return_slave_message_count Diagnostic sub command, Return count of messages addressed to remote slave. +client.return_slave_no_ack_count Diagnostic sub command, Return count of NO ACK exceptions sent by remote slave. +client.return_slave_no_response_count Diagnostic sub command, Return count of No responses by remote slave. +client.set_baudrate Baudrate setter. +client.set_bytesize Byte size setter. +client.set_parity Parity Setter. +client.set_port Serial Port setter. +client.set_stopbits Stop bit setter. +client.set_timeout Read timeout setter. +client.silent_interval Read Only! +client.state Read Only! +client.stopbits Read Only! +client.timeout Read Only! +client.write_coil Write `value` to coil at `address`. +client.write_coils Write `value` to coil at `address`. +client.write_register Write `value` to register at `address`. +client.write_registers Write list of `values` to registers starting at `address`. +result.decode Decode the register response to known formatters. +result.raw Return raw result dict. + +``` + +Every command has auto suggetion on the arguments supported , supply arg and value are to be supplied in `arg=val` format. +``` + +> client.read_holding_registers count=4 address=9 unit=1 +{ + "registers": [ + 60497, + 47134, + 34091, + 15424 + ] +} +``` + +The last result could be accessed with `result.raw` command +``` +> result.raw +{ + "registers": [ + 15626, + 55203, + 28733, + 18368 + ] +} +``` + +For Holding and Input register reads, the decoded value could be viewed with `result.decode` +``` +> result.decode word_order=little byte_order=little formatters=float64 +28.17 + +> +``` + +Client settings could be retrieved and altered as well. +``` +> # For serial settings + +> # Check the serial mode +> client.method +"rtu" + +> client.get_serial_settings +{ + "t1.5": 0.00171875, + "baudrate": 9600, + "read timeout": 0.5, + "port": "/dev/ptyp0", + "t3.5": 0.00401, + "bytesize": 8, + "parity": "N", + "stopbits": 1.0 +} +> client.set_timeout value=1 +null + +> client.get_timeout +1.0 + +> client.get_serial_settings +{ + "t1.5": 0.00171875, + "baudrate": 9600, + "read timeout": 1.0, + "port": "/dev/ptyp0", + "t3.5": 0.00401, + "bytesize": 8, + "parity": "N", + "stopbits": 1.0 +} + +``` + +To Send broadcast requests, use `--broadcast-support` and send requests with unit id as `0`. +`write_coil`, `write_coils`, `write_register`, `write_registers` are supported. + +``` +✗ pymodbus.console --broadcast-support tcp --host 192.168.1.8 --port 5020 + +---------------------------------------------------------------------------- +__________ _____ .___ __________ .__ +\______ \___.__. / \ ____ __| _/ \______ \ ____ ______ | | + | ___< | |/ \ / \ / _ \ / __ | | _// __ \\____ \| | + | | \___ / Y ( <_> ) /_/ | | | \ ___/| |_> > |__ + |____| / ____\____|__ /\____/\____ | /\ |____|_ /\___ > __/|____/ + \/ \/ \/ \/ \/ \/|__| + v1.2.0 - [pymodbus, version 2.4.0] +---------------------------------------------------------------------------- + +> client.write_registers address=0 values=10,20,30,40 unit=0 +{ + "broadcasted": true +} + +> client.write_registers address=0 values=10,20,30,40 unit=1 +{ + "address": 0, + "count": 4 +} +``` + +## DEMO + +[![asciicast](https://asciinema.org/a/y1xOk7lm59U1bRBE2N1pDIj2o.png)](https://asciinema.org/a/y1xOk7lm59U1bRBE2N1pDIj2o) +[![asciicast](https://asciinema.org/a/edUqZN77fdjxL2toisiilJNwI.png)](https://asciinema.org/a/edUqZN77fdjxL2toisiilJNwI) + diff --git a/modbus/pymodbus/repl/__init__.py b/modbus/pymodbus/repl/__init__.py new file mode 100644 index 00000000..ff5581a3 --- /dev/null +++ b/modbus/pymodbus/repl/__init__.py @@ -0,0 +1,7 @@ +""" +Pymodbus REPL Module. + +Copyright (c) 2018 Riptide IO, Inc. All Rights Reserved. + +""" +from __future__ import absolute_import, unicode_literals \ No newline at end of file diff --git a/modbus/pymodbus/repl/client/__init__.py b/modbus/pymodbus/repl/client/__init__.py new file mode 100644 index 00000000..bc4e3948 --- /dev/null +++ b/modbus/pymodbus/repl/client/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright (c) 2020 by RiptideIO +All rights reserved. +""" diff --git a/modbus/pymodbus/repl/client/completer.py b/modbus/pymodbus/repl/client/completer.py new file mode 100644 index 00000000..426c5b29 --- /dev/null +++ b/modbus/pymodbus/repl/client/completer.py @@ -0,0 +1,156 @@ +""" +Command Completion for pymodbus REPL. + +Copyright (c) 2018 Riptide IO, Inc. All Rights Reserved. + +""" +from __future__ import absolute_import, unicode_literals +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.styles import Style +from prompt_toolkit.filters import Condition +from prompt_toolkit.application.current import get_app +from pymodbus.repl.client.helper import get_commands +from pymodbus.compat import string_types + + +@Condition +def has_selected_completion(): + complete_state = get_app().current_buffer.complete_state + return (complete_state is not None and + complete_state.current_completion is not None) + + +style = Style.from_dict({ + 'completion-menu.completion': 'bg:#008888 #ffffff', + 'completion-menu.completion.current': 'bg:#00aaaa #000000', + 'scrollbar.background': 'bg:#88aaaa', + 'scrollbar.button': 'bg:#222222', +}) + + +class CmdCompleter(Completer): + """ + Completer for Pymodbus REPL. + """ + + def __init__(self, client=None, commands=None, ignore_case=True): + """ + + :param client: Modbus Client + :param commands: Commands to be added for Completion (list) + :param ignore_case: Ignore Case while looking up for commands + """ + self._commands = commands or get_commands(client) + self._commands['help'] = "" + self._command_names = self._commands.keys() + self.ignore_case = ignore_case + + @property + def commands(self): + return self._commands + + @property + def command_names(self): + return self._commands.keys() + + def completing_command(self, words, word_before_cursor): + """ + Determine if we are dealing with supported command. + + :param words: Input text broken in to word tokens. + :param word_before_cursor: The current word before the cursor, \ + which might be one or more blank spaces. + :return: + """ + if len(words) == 1 and word_before_cursor != '': + return True + else: + return False + + def completing_arg(self, words, word_before_cursor): + """ + Determine if we are currently completing an argument. + + :param words: The input text broken into word tokens. + :param word_before_cursor: The current word before the cursor, \ + which might be one or more blank spaces. + :return: Specifies whether we are currently completing an arg. + """ + if len(words) > 1 and word_before_cursor != '': + return True + else: + return False + + def arg_completions(self, words, word_before_cursor): + """ + Generates arguments completions based on the input. + + :param words: The input text broken into word tokens. + :param word_before_cursor: The current word before the cursor, \ + which might be one or more blank spaces. + :return: A list of completions. + """ + cmd = words[0].strip() + cmd = self._commands.get(cmd, None) + if cmd: + return cmd + + def _get_completions(self, word, word_before_cursor): + if self.ignore_case: + word_before_cursor = word_before_cursor.lower() + return self.word_matches(word, word_before_cursor) + + def word_matches(self, word, word_before_cursor): + """ + Match the word and word before cursor + + :param words: The input text broken into word tokens. + :param word_before_cursor: The current word before the cursor, \ + which might be one or more blank spaces. + :return: True if matched. + + """ + if self.ignore_case: + word = word.lower() + return word.startswith(word_before_cursor) + + def get_completions(self, document, complete_event): + """ + Get completions for the current scope. + + :param document: An instance of `prompt_toolkit.Document`. + :param complete_event: (Unused). + :return: Yields an instance of `prompt_toolkit.completion.Completion`. + """ + word_before_cursor = document.get_word_before_cursor(WORD=True) + text = document.text_before_cursor.lstrip() + words = document.text.strip().split() + meta = None + commands = [] + if len(words) == 0: + # yield commands + pass + if self.completing_command(words, word_before_cursor): + commands = self._command_names + c_meta = { + k: v.help_text + if not isinstance(v, string_types) + else v for k, v in self._commands.items() + } + meta = lambda x: (x, c_meta.get(x, '')) + else: + if not list(filter(lambda cmd: any(x == cmd for x in words), + self._command_names)): + # yield commands + pass + + if ' ' in text: + command = self.arg_completions(words, word_before_cursor) + commands = list(command.get_completion()) + commands = list(filter(lambda cmd: not(any(cmd in x for x in words)), commands)) + meta = command.get_meta + for a in commands: + if self._get_completions(a, word_before_cursor): + cmd, display_meta = meta(a) if meta else ('', '') + yield Completion(a, -len(word_before_cursor), + display_meta=display_meta) diff --git a/modbus/pymodbus/repl/client/helper.py b/modbus/pymodbus/repl/client/helper.py new file mode 100644 index 00000000..eb7ede64 --- /dev/null +++ b/modbus/pymodbus/repl/client/helper.py @@ -0,0 +1,327 @@ +""" +Helper Module for REPL actions. + +Copyright (c) 2018 Riptide IO, Inc. All Rights Reserved. + +""" +from __future__ import absolute_import, unicode_literals +import json +import pygments +import inspect +from collections import OrderedDict +from pygments.lexers.data import JsonLexer +from prompt_toolkit.formatted_text import PygmentsTokens, HTML +from prompt_toolkit import print_formatted_text + +from pymodbus.payload import BinaryPayloadDecoder, Endian +from pymodbus.compat import PYTHON_VERSION, IS_PYTHON2, string_types, izip + +predicate = inspect.ismethod +if IS_PYTHON2 or PYTHON_VERSION < (3, 3): + argspec = inspect.getargspec +else: + predicate = inspect.isfunction + argspec = inspect.signature + + +FORMATTERS = { + 'int8': 'decode_8bit_int', + 'int16': 'decode_16bit_int', + 'int32': 'decode_32bit_int', + 'int64': 'decode_64bit_int', + 'uint8': 'decode_8bit_uint', + 'uint16': 'decode_16bit_uint', + 'uint32': 'decode_32bit_uint', + 'uint64': 'decode_64bit_int', + 'float16': 'decode_16bit_float', + 'float32': 'decode_32bit_float', + 'float64': 'decode_64bit_float', +} + + +DEFAULT_KWARGS = { + 'unit': 'Slave address' +} + +OTHER_COMMANDS = { + "result.raw": "Show RAW Result", + "result.decode": "Decode register response to known formats", +} +EXCLUDE = ['execute', 'recv', 'send', 'trace', 'set_debug'] +CLIENT_METHODS = [ + 'connect', 'close', 'idle_time', 'is_socket_open', 'get_port', 'set_port', + 'get_stopbits', 'set_stopbits', 'get_bytesize', 'set_bytesize', + 'get_parity', 'set_parity', 'get_baudrate', 'set_baudrate', 'get_timeout', + 'set_timeout', 'get_serial_settings' + +] +CLIENT_ATTRIBUTES = [] + + +class Command(object): + """ + Class representing Commands to be consumed by Completer. + """ + def __init__(self, name, signature, doc, unit=False): + """ + + :param name: Name of the command + :param signature: inspect object + :param doc: Doc string for the command + :param unit: Use unit as additional argument in the command . + """ + self.name = name + self.doc = doc.split("\n") if doc else " ".join(name.split("_")) + self.help_text = self._create_help() + self.param_help = self._create_arg_help() + if signature: + if IS_PYTHON2: + self._params = signature + else: + self._params = signature.parameters + self.args = self.create_completion() + else: + self._params = '' + + if self.name.startswith("client.") and unit: + self.args.update(**DEFAULT_KWARGS) + + def _create_help(self): + doc = filter(lambda d: d, self.doc) + cmd_help = list(filter( + lambda x: not x.startswith(":param") and not x.startswith( + ":return"), doc)) + return " ".join(cmd_help).strip() + + def _create_arg_help(self): + param_dict = {} + params = list(filter(lambda d: d.strip().startswith(":param"), + self.doc)) + for param in params: + param, help = param.split(":param")[1].strip().split(":") + param_dict[param] = help + return param_dict + + def create_completion(self): + """ + Create command completion meta data. + + :return: + """ + words = {} + + def _create(entry, default): + if entry not in ['self', 'kwargs']: + if isinstance(default, (int, string_types)): + entry += "={}".format(default) + return entry + + if IS_PYTHON2: + if not self._params.defaults: + defaults = [None]*len(self._params.args) + else: + defaults = list(self._params.defaults) + missing = len(self._params.args) - len(defaults) + if missing > 1: + defaults.extend([None]*missing) + defaults.insert(0, None) + for arg, default in izip(self._params.args, defaults): + entry = _create(arg, default) + if entry: + entry, meta = self.get_meta(entry) + words[entry] = help + else: + for arg in self._params.values(): + entry = _create(arg.name, arg.default) + if entry: + entry, meta = self.get_meta(entry) + words[entry] = meta + + return words + + def get_completion(self): + """ + Gets a list of completions. + + :return: + """ + return self.args.keys() + + def get_meta(self, cmd): + """ + Get Meta info of a given command. + + :param cmd: Name of command. + :return: Dict containing meta info. + """ + cmd = cmd.strip() + cmd = cmd.split("=")[0].strip() + return cmd, self.param_help.get(cmd, '') + + def __str__(self): + if self.doc: + return "Command {:>50}{:<20}".format(self.name, self.doc) + return "Command {}".format(self.name) + + +def _get_requests(members): + commands = list(filter(lambda x: (x[0] not in EXCLUDE + and x[0] not in CLIENT_METHODS + and callable(x[1])), + members)) + commands = { + "client.{}".format(c[0]): + Command("client.{}".format(c[0]), + argspec(c[1]), inspect.getdoc(c[1]), unit=True) + for c in commands if not c[0].startswith("_") + } + return commands + + +def _get_client_methods(members): + commands = list(filter(lambda x: (x[0] not in EXCLUDE + and x[0] in CLIENT_METHODS), + members)) + commands = { + "client.{}".format(c[0]): + Command("client.{}".format(c[0]), + argspec(c[1]), inspect.getdoc(c[1]), unit=False) + for c in commands if not c[0].startswith("_") + } + return commands + + +def _get_client_properties(members): + global CLIENT_ATTRIBUTES + commands = list(filter(lambda x: not callable(x[1]), members)) + commands = { + "client.{}".format(c[0]): + Command("client.{}".format(c[0]), None, "Read Only!", unit=False) + for c in commands if (not c[0].startswith("_") + and isinstance(c[1], (string_types, int, float))) + } + CLIENT_ATTRIBUTES.extend(list(commands.keys())) + return commands + + +def get_commands(client): + """ + Helper method to retrieve all required methods and attributes of a client \ + object and convert it to commands. + + :param client: Modbus Client object. + :return: + """ + commands = dict() + members = inspect.getmembers(client) + requests = _get_requests(members) + client_methods = _get_client_methods(members) + client_attr = _get_client_properties(members) + + result_commands = inspect.getmembers(Result, predicate=predicate) + result_commands = { + "result.{}".format(c[0]): + Command("result.{}".format(c[0]), argspec(c[1]), + inspect.getdoc(c[1])) + for c in result_commands if (not c[0].startswith("_") + and c[0] != "print_result") + } + commands.update(requests) + commands.update(client_methods) + commands.update(client_attr) + commands.update(result_commands) + return commands + + +class Result(object): + """ + Represent result command. + """ + function_code = None + data = None + + def __init__(self, result): + """ + :param result: Response of a modbus command. + """ + if isinstance(result, dict): # Modbus response + self.function_code = result.pop('function_code', None) + self.data = dict(result) + else: + self.data = result + + def decode(self, formatters, byte_order='big', word_order='big'): + """ + Decode the register response to known formatters. + + :param formatters: int8/16/32/64, uint8/16/32/64, float32/64 + :param byte_order: little/big + :param word_order: little/big + :return: Decoded Value + """ + # Read Holding Registers (3) + # Read Input Registers (4) + # Read Write Registers (23) + if not isinstance(formatters, (list, tuple)): + formatters = [formatters] + + if self.function_code not in [3, 4, 23]: + print_formatted_text( + HTML("Decoder works only for registers!!")) + return + byte_order = (Endian.Little if byte_order.strip().lower() == "little" + else Endian.Big) + word_order = (Endian.Little if word_order.strip().lower() == "little" + else Endian.Big) + decoder = BinaryPayloadDecoder.fromRegisters(self.data.get('registers'), + byteorder=byte_order, + wordorder=word_order) + for formatter in formatters: + formatter = FORMATTERS.get(formatter) + if not formatter: + print_formatted_text( + HTML("Invalid Formatter - {}" + "!!".format(formatter))) + return + decoded = getattr(decoder, formatter)() + self.print_result(decoded) + + def raw(self): + """ + Return raw result dict. + + :return: + """ + self.print_result() + + def _process_dict(self, d): + new_dict = OrderedDict() + for k, v in d.items(): + if isinstance(v, bytes): + v = v.decode('utf-8') + elif isinstance(v, dict): + v = self._process_dict(v) + elif isinstance(v, (list, tuple)): + v = [v1.decode('utf-8') if isinstance(v1, bytes) else v1 + for v1 in v ] + new_dict[k] = v + return new_dict + + def print_result(self, data=None): + """ + Prettu print result object. + + :param data: Data to be printed. + :return: + """ + data = data or self.data + if isinstance(data, dict): + data = self._process_dict(data) + elif isinstance(data, (list, tuple)): + data = [v.decode('utf-8') if isinstance(v, bytes) else v + for v in data] + elif isinstance(data, bytes): + data = data.decode('utf-8') + tokens = list(pygments.lex(json.dumps(data, indent=4), + lexer=JsonLexer())) + print_formatted_text(PygmentsTokens(tokens)) diff --git a/modbus/pymodbus/repl/client/main.py b/modbus/pymodbus/repl/client/main.py new file mode 100644 index 00000000..8dc5583c --- /dev/null +++ b/modbus/pymodbus/repl/client/main.py @@ -0,0 +1,384 @@ +""" +Pymodbus REPL Entry point. + +Copyright (c) 2018 Riptide IO, Inc. All Rights Reserved. + +""" +from __future__ import absolute_import, unicode_literals +try: + import click +except ImportError: + print("click not installed!! Install with 'pip install click'") + exit(1) +try: + from prompt_toolkit import PromptSession, print_formatted_text +except ImportError: + print("prompt toolkit is not installed!! " + "Install with 'pip install prompt_toolkit --upgrade'") + exit(1) + +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.styles import Style +from prompt_toolkit.key_binding import KeyBindings + +from pygments.lexers.python import PythonLexer +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.history import FileHistory +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from pymodbus.version import version +from pymodbus.repl.client.completer import CmdCompleter, has_selected_completion +from pymodbus.repl.client.helper import Result, CLIENT_ATTRIBUTES + +click.disable_unicode_literals_warning = True + +TITLE = """ +---------------------------------------------------------------------------- +__________ _____ .___ __________ .__ +\______ \___.__. / \ ____ __| _/ \______ \ ____ ______ | | + | ___< | |/ \ / \ / _ \ / __ | | _// __ \\\____ \| | + | | \___ / Y ( <_> ) /_/ | | | \ ___/| |_> > |__ + |____| / ____\____|__ /\____/\____ | /\ |____|_ /\___ > __/|____/ + \/ \/ \/ \/ \/ \/|__| + v{} - {} +---------------------------------------------------------------------------- +""".format("1.3.0", version) + +log = None + +style = Style.from_dict({ + 'completion-menu.completion': 'bg:#008888 #ffffff', + 'completion-menu.completion.current': 'bg:#00aaaa #000000', + 'scrollbar.background': 'bg:#88aaaa', + 'scrollbar.button': 'bg:#222222', +}) + + +def bottom_toolbar(): + """ + Console toolbar. + :return: + """ + return HTML('Press ' + ' to exit! Type "help" for list of available commands') + + +class CaseInsenstiveChoice(click.Choice): + """ + Case Insensitive choice for click commands and options + """ + def convert(self, value, param, ctx): + """ + Convert args to uppercase for evaluation. + + """ + if value is None: + return None + return super(CaseInsenstiveChoice, self).convert( + value.strip().upper(), param, ctx) + + +class NumericChoice(click.Choice): + """ + Numeric choice for click arguments and options. + """ + def __init__(self, choices, typ): + self.typ = typ + super(NumericChoice, self).__init__(choices) + + def convert(self, value, param, ctx): + # Exact match + if value in self.choices: + return self.typ(value) + + if ctx is not None and ctx.token_normalize_func is not None: + value = ctx.token_normalize_func(value) + for choice in self.casted_choices: + if ctx.token_normalize_func(choice) == value: + return choice + + self.fail('invalid choice: %s. (choose from %s)' % + (value, ', '.join(self.choices)), param, ctx) + + +def cli(client): + kb = KeyBindings() + + @kb.add('c-space') + def _(event): + """ + Initialize autocompletion, or select the next completion. + """ + buff = event.app.current_buffer + if buff.complete_state: + buff.complete_next() + else: + buff.start_completion(select_first=False) + + @kb.add('enter', filter=has_selected_completion) + def _(event): + """ + Makes the enter key work as the tab key only when showing the menu. + """ + + event.current_buffer.complete_state = None + b = event.cli.current_buffer + b.complete_state = None + + def _process_args(args, string=True): + kwargs = {} + execute = True + skip_index = None + for i, arg in enumerate(args): + if i == skip_index: + continue + arg = arg.strip() + if "=" in arg: + a, val = arg.split("=") + if not string: + if "," in val: + val = val.split(",") + val = [int(v) for v in val] + else: + val = int(val) + kwargs[a] = val + else: + a, val = arg, args[i + 1] + try: + if not string: + if "," in val: + val = val.split(",") + val = [int(v) for v in val] + else: + val = int(val) + kwargs[a] = val + skip_index = i + 1 + except TypeError: + click.secho("Error parsing arguments!", + fg='yellow') + execute = False + break + except ValueError: + click.secho("Error parsing argument", + fg='yellow') + execute = False + break + return kwargs, execute + + session = PromptSession(lexer=PygmentsLexer(PythonLexer), + completer=CmdCompleter(client), style=style, + complete_while_typing=True, + bottom_toolbar=bottom_toolbar, + key_bindings=kb, + history=FileHistory('../.pymodhis'), + auto_suggest=AutoSuggestFromHistory()) + click.secho("{}".format(TITLE), fg='green') + result = None + while True: + try: + + text = session.prompt('> ', complete_while_typing=True) + if text.strip().lower() == 'help': + print_formatted_text(HTML("Available commands:")) + for cmd, obj in sorted(session.completer.commands.items()): + if cmd != 'help': + print_formatted_text( + HTML("{:45s}" + "{:100s}" + "".format(cmd, obj.help_text))) + + continue + elif text.strip().lower() == 'exit': + raise EOFError() + elif text.strip().lower().startswith("client."): + try: + text = text.strip().split() + cmd = text[0].split(".")[1] + args = text[1:] + kwargs, execute = _process_args(args, string=False) + if execute: + if text[0] in CLIENT_ATTRIBUTES: + result = Result(getattr(client, cmd)) + else: + result = Result(getattr(client, cmd)(**kwargs)) + result.print_result() + except Exception as e: + click.secho(repr(e), fg='red') + elif text.strip().lower().startswith("result."): + if result: + words = text.lower().split() + if words[0] == 'result.raw': + result.raw() + if words[0] == 'result.decode': + args = words[1:] + kwargs, execute = _process_args(args) + if execute: + result.decode(**kwargs) + except KeyboardInterrupt: + continue # Control-C pressed. Try again. + except EOFError: + break # Control-D pressed. + except Exception as e: # Handle all other exceptions + click.secho(str(e), fg='red') + + click.secho('GoodBye!', fg='blue') + + +@click.group('pymodbus-repl') +@click.version_option(version, message=TITLE) +@click.option("--verbose", is_flag=True, default=False, help="Verbose logs") +@click.option("--broadcast-support", is_flag=True, default=False, + help="Support broadcast messages") +@click.option("--retry-on-empty", is_flag=True, default=False, + help="Retry on empty response") +@click.option("--retry-on-error", is_flag=True, default=False, + help="Retry on error response") +@click.option("--retries", default=3, help="Retry count") +@click.option("--reset-socket/--no-reset-socket", default=True, help="Reset client socket on error") +@click.pass_context +def main(ctx, verbose, broadcast_support, retry_on_empty, + retry_on_error, retries, reset_socket): + if verbose: + global log + import logging + format = ('%(asctime)-15s %(threadName)-15s ' + '%(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s') + log = logging.getLogger('pymodbus') + logging.basicConfig(format=format) + log.setLevel(logging.DEBUG) + ctx.obj = { + "broadcast": broadcast_support, + "retry_on_empty": retry_on_empty, + "retry_on_invalid": retry_on_error, + "retries": retries, + "reset_socket": reset_socket + } + + +@main.command("tcp") +@click.pass_context +@click.option( + "--host", + default='localhost', + help="Modbus TCP IP " +) +@click.option( + "--port", + default=502, + type=int, + help="Modbus TCP port", +) +@click.option( + "--framer", + default='tcp', + type=str, + help="Override the default packet framer tcp|rtu", +) +def tcp(ctx, host, port, framer): + from pymodbus.repl.client.mclient import ModbusTcpClient + kwargs = dict(host=host, port=port) + kwargs.update(**ctx.obj) + if framer == 'rtu': + from pymodbus.framer.rtu_framer import ModbusRtuFramer + kwargs['framer'] = ModbusRtuFramer + client = ModbusTcpClient(**kwargs) + cli(client) + + +@main.command("serial") +@click.pass_context +@click.option( + "--method", + default='rtu', + type=str, + help="Modbus Serial Mode (rtu/ascii)", +) +@click.option( + "--port", + default=None, + type=str, + help="Modbus RTU port", +) +@click.option( + "--baudrate", + help="Modbus RTU serial baudrate to use. Defaults to 9600", + default=9600, + type=int + ) +@click.option( + "--bytesize", + help="Modbus RTU serial Number of data bits. " + "Possible values: FIVEBITS, SIXBITS, SEVENBITS, " + "EIGHTBITS. Defaults to 8", + type=NumericChoice(["5", "6", "7", "8"], int), + default="8" +) +@click.option( + "--parity", + help="Modbus RTU serial parity. " + " Enable parity checking. Possible values: " + "PARITY_NONE, PARITY_EVEN, PARITY_ODD PARITY_MARK, " + "PARITY_SPACE. Default to 'N'", + default='N', + type=CaseInsenstiveChoice(['N', 'E', 'O', 'M', 'S']) +) +@click.option( + "--stopbits", + help="Modbus RTU serial stop bits. " + "Number of stop bits. Possible values: STOPBITS_ONE, " + "STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO. Default to '1'", + default="1", + type=NumericChoice(["1", "1.5", "2"], float), +) +@click.option( + "--xonxoff", + help="Modbus RTU serial xonxoff. Enable software flow control." + "Defaults to 0", + default=0, + type=int +) +@click.option( + "--rtscts", + help="Modbus RTU serial rtscts. Enable hardware (RTS/CTS) flow " + "control. Defaults to 0", + default=0, + type=int +) +@click.option( + "--dsrdtr", + help="Modbus RTU serial dsrdtr. Enable hardware (DSR/DTR) flow " + "control. Defaults to 0", + default=0, + type=int +) +@click.option( + "--timeout", + help="Modbus RTU serial read timeout. Defaults to 0.025 sec", + default=0.25, + type=float +) +@click.option( + "--write-timeout", + help="Modbus RTU serial write timeout. Defaults to 2 sec", + default=2, + type=float +) +def serial(ctx, method, port, baudrate, bytesize, parity, stopbits, xonxoff, + rtscts, dsrdtr, timeout, write_timeout): + from pymodbus.repl.client.mclient import ModbusSerialClient + client = ModbusSerialClient(method=method, + port=port, + baudrate=baudrate, + bytesize=bytesize, + parity=parity, + stopbits=stopbits, + xonxoff=xonxoff, + rtscts=rtscts, + dsrdtr=dsrdtr, + timeout=timeout, + write_timeout=write_timeout, + **ctx.obj) + cli(client) + + +if __name__ == "__main__": + main() diff --git a/modbus/pymodbus/repl/client/mclient.py b/modbus/pymodbus/repl/client/mclient.py new file mode 100644 index 00000000..b84296ed --- /dev/null +++ b/modbus/pymodbus/repl/client/mclient.py @@ -0,0 +1,734 @@ +""" +Modbus Clients to be used with REPL. + +Copyright (c) 2018 Riptide IO, Inc. All Rights Reserved. + +""" +from __future__ import absolute_import, unicode_literals +import functools +from pymodbus.pdu import ModbusExceptions, ExceptionResponse +from pymodbus.exceptions import ModbusIOException +from pymodbus.client.sync import ModbusSerialClient as _ModbusSerialClient +from pymodbus.client.sync import ModbusTcpClient as _ModbusTcpClient +from pymodbus.mei_message import ReadDeviceInformationRequest +from pymodbus.other_message import (ReadExceptionStatusRequest, + ReportSlaveIdRequest, + GetCommEventCounterRequest, + GetCommEventLogRequest) +from pymodbus.diag_message import ( + ReturnQueryDataRequest, + RestartCommunicationsOptionRequest, + ReturnDiagnosticRegisterRequest, + ChangeAsciiInputDelimiterRequest, + ForceListenOnlyModeRequest, + ClearCountersRequest, + ReturnBusMessageCountRequest, + ReturnBusCommunicationErrorCountRequest, + ReturnBusExceptionErrorCountRequest, + ReturnSlaveMessageCountRequest, + ReturnSlaveNoResponseCountRequest, + ReturnSlaveNAKCountRequest, + ReturnSlaveBusyCountRequest, + ReturnSlaveBusCharacterOverrunCountRequest, + ReturnIopOverrunCountRequest, + ClearOverrunCountRequest, + GetClearModbusPlusRequest) + + +def make_response_dict(resp): + rd = { + 'function_code': resp.function_code, + 'address': resp.address + } + if hasattr(resp, "value"): + rd['value'] = resp.value + elif hasattr(resp, 'values'): + rd['values'] = resp.values + elif hasattr(resp, 'count'): + rd['count'] = resp.count + + return rd + + +def handle_brodcast(func): + @functools.wraps(func) + def _wrapper(*args, **kwargs): + self = args[0] + resp = func(*args, **kwargs) + if kwargs.get("unit") == 0 and self.broadcast_enable: + return { + 'broadcasted': True + } + if not resp.isError(): + return make_response_dict(resp) + else: + return ExtendedRequestSupport._process_exception(resp, **kwargs) + return _wrapper + + +class ExtendedRequestSupport(object): + + @staticmethod + def _process_exception(resp, **kwargs): + unit = kwargs.get("unit") + if unit == 0: + err = { + "message": "Broadcast message, ignoring errors!!!" + } + else: + if isinstance(resp, ExceptionResponse): + err = { + 'original_function_code': "{} ({})".format( + resp.original_code, hex(resp.original_code)), + 'error_function_code': "{} ({})".format( + resp.function_code, hex(resp.function_code)), + 'exception code': resp.exception_code, + 'message': ModbusExceptions.decode(resp.exception_code) + } + elif isinstance(resp, ModbusIOException): + err = { + 'original_function_code': "{} ({})".format( + resp.fcode, hex(resp.fcode)), + 'error': resp.message + } + else: + err = { + 'error': str(resp) + } + return err + + def read_coils(self, address, count=1, **kwargs): + """ + Reads `count` coils from a given slave starting at `address`. + + :param address: The starting address to read from + :param count: The number of coils to read + :param unit: The slave unit this request is targeting + :returns: List of register values + """ + resp = super(ExtendedRequestSupport, self).read_coils(address, + count, **kwargs) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'bits': resp.bits + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def read_discrete_inputs(self, address, count=1, **kwargs): + """ + Reads `count` number of discrete inputs starting at offset `address`. + + :param address: The starting address to read from + :param count: The number of coils to read + :param unit: The slave unit this request is targeting + :return: List of bits + """ + resp = super(ExtendedRequestSupport, + self).read_discrete_inputs(address, count, **kwargs) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'bits': resp.bits + } + else: + return ExtendedRequestSupport._process_exception(resp) + + @handle_brodcast + def write_coil(self, address, value, **kwargs): + """ + Write `value` to coil at `address`. + + :param address: coil offset to write to + :param value: bit value to write + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).write_coil( + address, value, **kwargs) + return resp + + @handle_brodcast + def write_coils(self, address, values, **kwargs): + """ + Write `value` to coil at `address`. + + :param address: coil offset to write to + :param values: list of bit values to write (comma separated) + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).write_coils( + address, values, **kwargs) + return resp + + @handle_brodcast + def write_register(self, address, value, **kwargs): + """ + Write `value` to register at `address`. + + :param address: register offset to write to + :param value: register value to write + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).write_register( + address, value, **kwargs) + return resp + + @handle_brodcast + def write_registers(self, address, values, **kwargs): + """ + Write list of `values` to registers starting at `address`. + + :param address: register offset to write to + :param values: list of register value to write (comma separated) + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).write_registers( + address, values, **kwargs) + return resp + + def read_holding_registers(self, address, count=1, **kwargs): + """ + Read `count` number of holding registers starting at `address`. + + :param address: starting register offset to read from + :param count: Number of registers to read + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).read_holding_registers( + address, count, **kwargs) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'registers': resp.registers + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def read_input_registers(self, address, count=1, **kwargs): + """ + Read `count` number of input registers starting at `address`. + + :param address: starting register offset to read from to + :param count: Number of registers to read + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).read_input_registers( + address, count, **kwargs) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'registers': resp.registers + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def readwrite_registers(self, read_address, read_count, write_address, + write_registers, **kwargs): + """ + Read `read_count` number of holding registers starting at \ + `read_address` and write `write_registers` \ + starting at `write_address`. + + :param read_address: register offset to read from + :param read_count: Number of registers to read + :param write_address: register offset to write to + :param write_registers: List of register values to write (comma separated) + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).readwrite_registers( + read_address=read_address, + read_count=read_count, + write_address=write_address, + write_registers=write_registers, + **kwargs + ) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'registers': resp.registers + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def mask_write_register(self, address=0x0000, + and_mask=0xffff, or_mask=0x0000, **kwargs): + """ + Mask content of holding register at `address` \ + with `and_mask` and `or_mask`. + + :param address: Reference address of register + :param and_mask: And Mask + :param or_mask: OR Mask + :param unit: The slave unit this request is targeting + :return: + """ + resp = super(ExtendedRequestSupport, self).read_input_registers( + address=address, and_mask=and_mask, or_mask=or_mask, **kwargs) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'address': resp.address, + 'and mask': resp.and_mask, + 'or mask': resp.or_mask + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def read_device_information(self, read_code=None, + object_id=0x00, **kwargs): + """ + Read the identification and additional information of remote slave. + + :param read_code: Read Device ID code (0x01/0x02/0x03/0x04) + :param object_id: Identification of the first object to obtain. + :param unit: The slave unit this request is targeting + :return: + """ + request = ReadDeviceInformationRequest(read_code, object_id, **kwargs) + resp = self.execute(request) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'information': resp.information, + 'object count': resp.number_of_objects, + 'conformity': resp.conformity, + 'next object id': resp.next_object_id, + 'more follows': resp.more_follows, + 'space left': resp.space_left + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def report_slave_id(self, **kwargs): + """ + Report information about remote slave ID. + + :param unit: The slave unit this request is targeting + :return: + """ + request = ReportSlaveIdRequest(**kwargs) + resp = self.execute(request) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'identifier': resp.identifier.decode('cp1252'), + 'status': resp.status, + 'byte count': resp.byte_count + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def read_exception_status(self, **kwargs): + """ + Read the contents of eight Exception Status outputs in a remote \ + device. + + :param unit: The slave unit this request is targeting + + :return: + + """ + request = ReadExceptionStatusRequest(**kwargs) + resp = self.execute(request) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'status': resp.status + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def get_com_event_counter(self, **kwargs): + """ + Read status word and an event count from the remote device's \ + communication event counter. + + :param unit: The slave unit this request is targeting + + :return: + + """ + request = GetCommEventCounterRequest(**kwargs) + resp = self.execute(request) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'status': resp.status, + 'count': resp.count + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def get_com_event_log(self, **kwargs): + """ + Read status word, event count, message count, and a field of event + bytes from the remote device. + + :param unit: The slave unit this request is targeting + :return: + """ + request = GetCommEventLogRequest(**kwargs) + resp = self.execute(request) + if not resp.isError(): + return { + 'function_code': resp.function_code, + 'status': resp.status, + 'message count': resp.message_count, + 'event count': resp.event_count, + 'events': resp.events, + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def _execute_diagnostic_request(self, request): + resp = self.execute(request) + if not resp.isError(): + return { + 'function code': resp.function_code, + 'sub function code': resp.sub_function_code, + 'message': resp.message + } + else: + return ExtendedRequestSupport._process_exception(resp) + + def return_query_data(self, message=0, **kwargs): + """ + Diagnostic sub command , Loop back data sent in response. + + :param message: Message to be looped back + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnQueryDataRequest(message, **kwargs) + return self._execute_diagnostic_request(request) + + def restart_comm_option(self, toggle=False, **kwargs): + """ + Diagnostic sub command, initialize and restart remote devices serial \ + interface and clear all of its communications event counters . + + :param toggle: Toggle Status [ON(0xff00)/OFF(0x0000] + :param unit: The slave unit this request is targeting + :return: + """ + request = RestartCommunicationsOptionRequest(toggle, **kwargs) + return self._execute_diagnostic_request(request) + + def return_diagnostic_register(self, data=0, **kwargs): + """ + Diagnostic sub command, Read 16-bit diagnostic register. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnDiagnosticRegisterRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def change_ascii_input_delimiter(self, data=0, **kwargs): + """ + Diagnostic sub command, Change message delimiter for future requests. + + :param data: New delimiter character + :param unit: The slave unit this request is targeting + :return: + """ + request = ChangeAsciiInputDelimiterRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def force_listen_only_mode(self, data=0, **kwargs): + """ + Diagnostic sub command, Forces the addressed remote device to \ + its Listen Only Mode. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ForceListenOnlyModeRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def clear_counters(self, data=0, **kwargs): + """ + Diagnostic sub command, Clear all counters and diag registers. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ClearCountersRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_bus_message_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of message detected on bus \ + by remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnBusMessageCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_bus_com_error_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of CRC errors \ + received by remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnBusCommunicationErrorCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_bus_exception_error_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of Modbus exceptions \ + returned by remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnBusExceptionErrorCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_slave_message_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of messages addressed to \ + remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnSlaveMessageCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_slave_no_response_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of No responses by remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnSlaveNoResponseCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_slave_no_ack_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of NO ACK exceptions sent \ + by remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnSlaveNAKCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_slave_busy_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of server busy exceptions sent \ + by remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnSlaveBusyCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_slave_bus_char_overrun_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of messages not handled \ + by remote slave due to character overrun condition. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnSlaveBusCharacterOverrunCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def return_iop_overrun_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Return count of iop overrun errors \ + by remote slave. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ReturnIopOverrunCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def clear_overrun_count(self, data=0, **kwargs): + """ + Diagnostic sub command, Clear over run counter. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = ClearOverrunCountRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + def get_clear_modbus_plus(self, data=0, **kwargs): + """ + Diagnostic sub command, Get or clear stats of remote \ + modbus plus device. + + :param data: Data field (0x0000) + :param unit: The slave unit this request is targeting + :return: + """ + request = GetClearModbusPlusRequest(data, **kwargs) + return self._execute_diagnostic_request(request) + + +class ModbusSerialClient(ExtendedRequestSupport, _ModbusSerialClient): + def __init__(self, method, **kwargs): + super(ModbusSerialClient, self).__init__(method, **kwargs) + + def get_port(self): + """ + Serial Port. + + :return: Current Serial port + """ + return self.port + + def set_port(self, value): + """ + Serial Port setter. + + :param value: New port + """ + self.port = value + if self.is_socket_open(): + self.close() + + def get_stopbits(self): + """ + Number of stop bits. + + :return: Current Stop bits + """ + return self.stopbits + + def set_stopbits(self, value): + """ + Stop bit setter. + + :param value: Possible values (1, 1.5, 2) + """ + self.stopbits = float(value) + if self.is_socket_open(): + self.close() + + def get_bytesize(self): + """ + Number of data bits. + + :return: Current bytesize + """ + return self.bytesize + + def set_bytesize(self, value): + """ + Byte size setter. + + :param value: Possible values (5, 6, 7, 8) + + """ + self.bytesize = int(value) + if self.is_socket_open(): + self.close() + + def get_parity(self): + """ + Enable Parity Checking. + + :return: Current parity setting + """ + return self.parity + + def set_parity(self, value): + """ + Parity Setter. + + :param value: Possible values ('N', 'E', 'O', 'M', 'S') + """ + self.parity = value + if self.is_socket_open(): + self.close() + + def get_baudrate(self): + """ + Serial Port baudrate. + + :return: Current baudrate + """ + return self.baudrate + + def set_baudrate(self, value): + """ + Baudrate setter. + + :param value: + """ + self.baudrate = int(value) + if self.is_socket_open(): + self.close() + + def get_timeout(self): + """ + Serial Port Read timeout. + + :return: Current read imeout. + """ + return self.timeout + + def set_timeout(self, value): + """ + Read timeout setter. + + :param value: Read Timeout in seconds + """ + self.timeout = float(value) + if self.is_socket_open(): + self.close() + + def get_serial_settings(self): + """ + Gets Current Serial port settings. + + :return: Current Serial settings as dict. + """ + return { + 'baudrate': self.baudrate, + 'port': self.port, + 'parity': self.parity, + 'stopbits': self.stopbits, + 'bytesize': self.bytesize, + 'read timeout': self.timeout, + 't1.5': self.inter_char_timeout, + 't3.5': self.silent_interval + } + + +class ModbusTcpClient(ExtendedRequestSupport, _ModbusTcpClient): + def __init__(self, **kwargs): + super(ModbusTcpClient, self).__init__(**kwargs) diff --git a/modbus/pymodbus/repl/server/__init__.py b/modbus/pymodbus/repl/server/__init__.py new file mode 100644 index 00000000..bc4e3948 --- /dev/null +++ b/modbus/pymodbus/repl/server/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright (c) 2020 by RiptideIO +All rights reserved. +""" diff --git a/modbus/pymodbus/repl/server/cli.py b/modbus/pymodbus/repl/server/cli.py new file mode 100644 index 00000000..0ab1a96f --- /dev/null +++ b/modbus/pymodbus/repl/server/cli.py @@ -0,0 +1,208 @@ +""" +Copyright (c) 2020 by RiptideIO +All rights reserved. +""" +import json +import click +import shutil +import logging + +from prompt_toolkit.shortcuts import clear +from prompt_toolkit.shortcuts.progress_bar import formatters +from prompt_toolkit.styles import Style + +from prompt_toolkit import PromptSession, print_formatted_text +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.completion import NestedCompleter +from prompt_toolkit.formatted_text import HTML + + +logger = logging.getLogger(__name__) + +TITLE = """ +__________ .______. _________ +\______ \___.__. _____ ____ __| _/\_ |__ __ __ ______ / _____/ ______________ __ ___________ + | ___< | |/ \ / _ \ / __ | | __ \| | \/ ___/ \_____ \_/ __ \_ __ \ \/ // __ \_ __ \\ + | | \___ | Y Y ( <_> ) /_/ | | \_\ \ | /\___ \ / \ ___/| | \/\ /\ ___/| | \/ + |____| / ____|__|_| /\____/\____ | |___ /____//____ > /_______ /\___ >__| \_/ \___ >__| + \/ \/ \/ \/ \/ \/ \/ \/ +""" + +SMALL_TITLE = "Pymodbus server..." +BOTTOM_TOOLBAR = HTML('(MODBUS SERVER) Type "help" ' + 'for list of available commands') +COMMAND_ARGS = ["response_type", "error_code", "delay_by", + "clear_after", "data_len"] +RESPONSE_TYPES = ["normal", "error", "delayed", "empty", "stray"] +COMMANDS = { + "manipulator": { + "response_type": None, + "error_code": None, + "delay_by": None, + "clear_after": None + }, + "exit": None, + "help": None, + "clear": None +} +USAGE = "manipulator response_type=|normal|error|delayed|empty|stray \n" \ + "\tAdditional parameters\n" \ + "\t\terror_code=<int> \n\t\tdelay_by=<in seconds> \n\t\t" \ + "clear_after=<clear after n messages int>" \ + "\n\t\tdata_len=<length of stray data (int)>\n" \ + "\n\tExample usage: \n\t" \ + "1. Send error response 3 for 4 requests\n\t" \ + " manipulator response_type=error error_code=3 clear_after=4\n\t" \ + "2. Delay outgoing response by 5 seconds indefinitely\n\t" \ + " manipulator response_type=delayed delay_by=5\n\t" \ + "3. Send empty response\n\t" \ + " manipulator response_type=empty\n\t" \ + "4. Send stray response of lenght 12 and revert to normal after 2 responses\n\t" \ + " manipulator response_type=stray data_len=11 clear_after=2\n\t" \ + "5. To disable response manipulation\n\t" \ + " manipulator response_type=normal" +COMMAND_HELPS = { + "manipulator": "Manipulate response from server.\nUsage: {}".format(USAGE), + "clear": "Clears screen" + +} + + +STYLE = Style.from_dict({"": "cyan"}) +CUSTOM_FORMATTERS = [ + formatters.Label(suffix=": "), + formatters.Bar(start="|", end="|", sym_a="#", sym_b="#", sym_c="-"), + formatters.Text(" "), + formatters.Text(" "), + formatters.TimeElapsed(), + formatters.Text(" "), + ] + + +def info(message): + if not isinstance(message, str): + message = str(message) + click.secho(message, fg="green") + + +def warning(message): + click.secho(str(message), fg="yellow") + + +def error(message): + click.secho(str(message), fg="red") + + +def get_terminal_width(): + return shutil.get_terminal_size()[0] + + +def print_help(): + print_formatted_text(HTML("Available commands:")) + for cmd, hlp in sorted(COMMAND_HELPS.items()): + print_formatted_text( + HTML("{:45s}{:100s}".format(cmd, hlp)) + ) + + +async def interactive_shell(server): + """ + CLI interactive shell + """ + col = get_terminal_width() + max_len = max([len(t) for t in TITLE.split("\n")]) + if col > max_len: + info(TITLE) + else: + print_formatted_text(HTML(''.format(SMALL_TITLE))) + info("") + completer = NestedCompleter.from_nested_dict(COMMANDS) + session = PromptSession("SERVER > ", + completer=completer, + bottom_toolbar=BOTTOM_TOOLBAR) + + # Run echo loop. Read text from stdin, and reply it back. + while True: + try: + invalid_command = False + result = await session.prompt_async() + if result == "exit": + await server.web_app.shutdown() + break + if result == "help": + print_help() + continue + if result == "clear": + clear() + continue + command = result.split() + if command: + if command[0] not in COMMANDS: + invalid_command = True + if invalid_command: + warning("Invalid command or invalid usage of command - {}".format(command)) + continue + if len(command) == 1: + warning("Usage: '{}'".format(USAGE)) + else: + args = command[1:] + skip_next = False + val_dict = {} + for index, arg in enumerate(args): + if skip_next: + skip_next = False + continue + if "=" in arg: + arg, value = arg.split("=") + else: + if arg in COMMAND_ARGS: + try: + value = args[index+1] + skip_next = True + except IndexError: + error("Missing value " + "for argument - {}".format(arg)) + warning("Usage: '{}'".format(USAGE)) + break + valid = True + if arg == "response_type": + if value not in RESPONSE_TYPES: + warning("Invalid response " + "type request - {}".format(value)) + warning("Choose from {}".format(RESPONSE_TYPES)) + valid = False + elif arg in ["error_code", "delay_by", + "clear_after", "data_len"]: + try: + value = int(value) + except ValueError: + warning("Expected integer " + "value for {}".format(arg)) + valid = False + + if valid: + val_dict[arg] = value + if val_dict: + server.update_manipulator_config(val_dict) + # server.manipulator_config = val_dict + # result = await run_command(tester, *command) + + except (EOFError, KeyboardInterrupt): + return + + +async def main(server): + # with patch_stdout(): + try: + await interactive_shell(server) + finally: + pass + warning("Bye Bye!!!") + + +async def run_repl(server): + await main(server) + + diff --git a/modbus/pymodbus/repl/server/main.py b/modbus/pymodbus/repl/server/main.py new file mode 100644 index 00000000..8d300f1f --- /dev/null +++ b/modbus/pymodbus/repl/server/main.py @@ -0,0 +1,117 @@ +""" +Copyright (c) 2020 by RiptideIO +All rights reserved. +""" +import asyncio +import json +import click +from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION +from pymodbus.framer.socket_framer import ModbusSocketFramer +from pymodbus.server.reactive.main import ( + ReactiveServer, DEFAULT_FRAMER, DEFUALT_HANDLERS) +from pymodbus.server.reactive.default_config import DEFUALT_CONFIG +from pymodbus.repl.server.cli import run_repl + +if IS_PYTHON3 and PYTHON_VERSION > (3, 7): + CANCELLED_ERROR = asyncio.exceptions.CancelledError +else: + CANCELLED_ERROR = asyncio.CancelledError + + +@click.group("ReactiveModbusServer") +@click.option("--host", default="localhost", help="Host address") +@click.option("--web-port", default=8080, help="Web app port") +@click.option("--broadcast-support", is_flag=True, + default=False, help="Support broadcast messages") +@click.option("--repl/--no-repl", is_flag=True, + default=True, help="Enable/Disable repl for server") +@click.option("--verbose", is_flag=True, + help="Run with debug logs enabled for pymodbus") +@click.pass_context +def server(ctx, host, web_port, broadcast_support, repl, verbose): + global logger + import logging + FORMAT = ('%(asctime)-15s %(threadName)-15s' + ' %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s') + pymodbus_logger = logging.getLogger("pymodbus") + logging.basicConfig(format=FORMAT) + logger = logging.getLogger(__name__) + if verbose: + pymodbus_logger.setLevel(logging.DEBUG) + logger.setLevel(logging.DEBUG) + else: + pymodbus_logger.setLevel(logging.ERROR) + logger.setLevel(logging.ERROR) + + ctx.obj = {"repl": repl, "host": host, "web_port": web_port, + "broadcast": broadcast_support} + + +@server.command("run") +@click.option("--modbus-server", default="tcp", + type=click.Choice(["tcp", "serial", "tls", "udp"], + case_sensitive=False), + help="Modbus server") +@click.option("--modbus-framer", default="socket", + type=click.Choice(["socket", "rtu", "tls", "ascii", "binary"], + case_sensitive=False), + help="Modbus framer to use") +@click.option("--modbus-port", default="5020", help="Modbus port") +@click.option("--modbus-unit-id", default=[1], type=int, + multiple=True, help="Modbus unit id") +@click.option("--modbus-config", type=click.Path(exists=True), + help="Path to additional modbus server config") +@click.option("-r", "--randomize", default=0, help="Randomize every `r` reads." + " 0=never, 1=always, " + "2=every-second-read, " + "and so on. " + "Applicable IR and DI.") +@click.pass_context +def run(ctx, modbus_server, modbus_framer, modbus_port, modbus_unit_id, + modbus_config, randomize): + """ + Run Reactive Modbus server exposing REST endpoint + for response manipulation. + """ + if not IS_PYTHON3: + click.secho("Pymodbus Server REPL not supported on python2", fg="read") + exit(1) + repl = ctx.obj.pop("repl") + web_app_config = ctx.obj + loop = asyncio.get_event_loop() + framer = DEFAULT_FRAMER.get(modbus_framer, ModbusSocketFramer) + if modbus_config: + with open(modbus_config) as f: + modbus_config = json.load(f) + else: + modbus_config = DEFUALT_CONFIG + modbus_config = modbus_config.get(modbus_server, {}) + if modbus_server != "serial": + modbus_port = int(modbus_port) + handler = modbus_config.pop("handler", "ModbusConnectedRequestHandler") + else: + handler = modbus_config.pop("handler", "ModbusSingleRequestHandler") + handler = DEFUALT_HANDLERS.get(handler.strip()) + + modbus_config["handler"] = handler + modbus_config["randomize"] = randomize + app = ReactiveServer.factory(modbus_server, framer, + modbus_port=modbus_port, + unit=modbus_unit_id, + loop=loop, + **web_app_config, **modbus_config) + try: + if repl: + loop.run_until_complete(app.run_async()) + + loop.run_until_complete(run_repl(app)) + loop.run_forever() + else: + app.run() + + except CANCELLED_ERROR: + print("Done!!!!!") + + +if __name__ == '__main__': + server() diff --git a/modbus/pymodbus/server/__init__.py b/modbus/pymodbus/server/__init__.py new file mode 100644 index 00000000..35350f6a --- /dev/null +++ b/modbus/pymodbus/server/__init__.py @@ -0,0 +1,2 @@ +''' +''' diff --git a/modbus/pymodbus/server/async_io.py b/modbus/pymodbus/server/async_io.py new file mode 100644 index 00000000..adf53a78 --- /dev/null +++ b/modbus/pymodbus/server/async_io.py @@ -0,0 +1,963 @@ +""" +Implementation of a Threaded Modbus Server +------------------------------------------ + +""" +from binascii import b2a_hex +import serial +from serial_asyncio import create_serial_connection +import ssl +import traceback + +import asyncio +from pymodbus.compat import PYTHON_VERSION +from pymodbus.constants import Defaults +from pymodbus.utilities import hexlify_packets +from pymodbus.factory import ServerDecoder +from pymodbus.datastore import ModbusServerContext +from pymodbus.device import ModbusControlBlock +from pymodbus.device import ModbusDeviceIdentification +from pymodbus.transaction import * +from pymodbus.exceptions import NotImplementedException, NoSuchSlaveException +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.compat import socketserver, byte2int + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Protocol Handlers +# --------------------------------------------------------------------------- # + +class ModbusBaseRequestHandler(asyncio.BaseProtocol): + """ Implements modbus slave wire protocol + This uses the asyncio.Protocol to implement the client handler. + + When a connection is established, the asyncio.Protocol.connection_made + callback is called. This callback will setup the connection and + create and schedule an asyncio.Task and assign it to running_task. + + running_task will be canceled upon connection_lost event. + """ + def __init__(self, owner): + self.server = owner + self.running = False + self.receive_queue = asyncio.Queue() + self.handler_task = None # coroutine to be run on asyncio loop + + def _log_exception(self): + if isinstance(self, ModbusConnectedRequestHandler): + _logger.error( + "Handler for stream [%s:%s] has " + "been canceled" % self.client_address[:2]) + elif isinstance(self, ModbusSingleRequestHandler): + _logger.error( + "Handler for serial port has been cancelled") + else: + sock_name = self.protocol._sock.getsockname() + _logger.error("Handler for UDP socket [%s] has " + "been canceled" % sock_name[1]) + + def connection_made(self, transport): + """ + asyncio.BaseProtocol callback for socket establish + + For streamed protocols (TCP) this will also correspond to an + entire conversation; however for datagram protocols (UDP) this + corresponds to the socket being opened + """ + try: + sockname = transport.get_extra_info('sockname') + if sockname is not None: + _logger.debug( + "Socket [%s:%s] opened" % transport.get_extra_info( + 'sockname')[:2]) + else: + if hasattr(transport, 'serial'): + _logger.debug( + "Serial connection opened on port: {}".format( + transport.serial.port) + ) + self.transport = transport + self.running = True + self.framer = self.server.framer(self.server.decoder, client=None) + + # schedule the connection handler on the event loop + if PYTHON_VERSION >= (3, 7): + self.handler_task = asyncio.create_task(self.handle()) + else: + self.handler_task = asyncio.ensure_future(self.handle()) + except Exception as ex: # pragma: no cover + _logger.error("Datastore unable to fulfill request: " + "%s; %s", ex, traceback.format_exc()) + + def connection_lost(self, exc): + """ + asyncio.BaseProtocol callback for socket tear down + + For streamed protocols any break in the network connection will + be reported here; for datagram protocols, only a teardown of the + socket itself will result in this call. + """ + try: + self.handler_task.cancel() + if exc is None: + self._log_exception() + else: # pragma: no cover + if hasattr(self, "client_address"): # TCP connection + _logger.debug("Client Disconnection {} due " + "to {}".format(*self.client_address, exc)) + + self.running = False + + except Exception as ex: # pragma: no cover + _logger.error("Datastore unable to fulfill request: " + "%s; %s", ex, traceback.format_exc()) + + async def handle(self): + """Asyncio coroutine which represents a single conversation between + the modbus slave and master + + Once the client connection is established, the data chunks will be + fed to this coroutine via the asyncio.Queue object which is fed by + the ModbusBaseRequestHandler class's callback Future. + + This callback future gets data from either + asyncio.DatagramProtocol.datagram_received or + from asyncio.BaseProtocol.data_received. + + This function will execute without blocking in the while-loop and + yield to the asyncio event loop when the frame is exhausted. + As a result, multiple clients can be interleaved without any + interference between them. + + For ModbusConnectedRequestHandler, each connection will be given an + instance of the handle() coroutine and this instance will be put in the + active_connections dict. Calling server_close will individually cancel + each running handle() task. + + For ModbusDisconnectedRequestHandler, a single handle() coroutine will + be started and maintained. Calling server_close will cancel that task. + + """ + reset_frame = False + while self.running: + try: + units = self.server.context.slaves() + # this is an asyncio.Queue await, it will never fail + data = await self._recv_() + if isinstance(data, tuple): + # addr is populated when talking over UDP + data, *addr = data + else: + addr = (None,) # empty tuple + + if not isinstance(units, (list, tuple)): + units = [units] + # if broadcast is enabled make sure to + # process requests to address 0 + if self.server.broadcast_enable: # pragma: no cover + if 0 not in units: + units.append(0) + + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('Handling data: ' + hexlify_packets(data)) + + single = self.server.context.single + self.framer.processIncomingPacket( + data=data, callback=lambda x: self.execute(x, *addr), + unit=units, single=single) + + except asyncio.CancelledError: + # catch and ignore cancelation errors + self._log_exception() + except Exception as e: + # force TCP socket termination as processIncomingPacket + # should handle applicaiton layer errors + # for UDP sockets, simply reset the frame + if isinstance(self, ModbusConnectedRequestHandler): + client_addr = self.client_address[:2] + _logger.error("Unknown exception '{}' on stream {} " + "forcing disconnect".format(e, client_addr)) + self.transport.close() + else: + _logger.error("Unknown error occurred %s" % e) + reset_frame = True # graceful recovery + finally: + if reset_frame: + self.framer.resetFrame() + reset_frame = False + + def execute(self, request, *addr): + """ The callback to call with the resulting message + + :param request: The decoded request message + """ + broadcast = False + try: + if self.server.broadcast_enable and request.unit_id == 0: + broadcast = True + # if broadcasting then execute on all slave contexts, + # note response will be ignored + for unit_id in self.server.context.slaves(): + response = request.execute(self.server.context[unit_id]) + else: + context = self.server.context[request.unit_id] + response = request.execute(context) + except NoSuchSlaveException as ex: + _logger.error("requested slave does " + "not exist: %s" % request.unit_id) + if self.server.ignore_missing_slaves: + return # the client will simply timeout waiting for a response + response = request.doException(merror.GatewayNoResponse) + except Exception as ex: + _logger.error("Datastore unable to fulfill request: " + "%s; %s", ex, traceback.format_exc()) + response = request.doException(merror.SlaveFailure) + # no response when broadcasting + if not broadcast: + response.transaction_id = request.transaction_id + response.unit_id = request.unit_id + skip_encoding = False + if self.server.response_manipulator: + response, skip_encoding = self.server.response_manipulator(response) + self.send(response, *addr, skip_encoding=skip_encoding) + + def send(self, message, *addr, **kwargs): + def __send(msg, *addr): + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('send: [%s]- %s' % (message, b2a_hex(msg))) + if addr == (None,): + self._send_(msg) + else: + self._send_(msg, *addr) + skip_encoding = kwargs.get("skip_encoding", False) + if skip_encoding: + __send(message, *addr) + elif message.should_respond: + # self.server.control.Counter.BusMessage += 1 + pdu = self.framer.buildPacket(message) + __send(pdu, *addr) + else: + _logger.debug("Skipping sending response!!") + + # ----------------------------------------------------------------------- # + # Derived class implementations + # ----------------------------------------------------------------------- # + + def _send_(self, data): # pragma: no cover + """ Send a request (string) to the network + + :param message: The unencoded modbus response + """ + raise NotImplementedException("Method not implemented " + "by derived class") + + async def _recv_(self): # pragma: no cover + """ Receive data from the network + + :return: + """ + raise NotImplementedException("Method not implemented " + "by derived class") + + +class ModbusConnectedRequestHandler(ModbusBaseRequestHandler,asyncio.Protocol): + """ Implements the modbus server protocol + + This uses asyncio.Protocol to implement + the client handler for a connected protocol (TCP). + """ + + def connection_made(self, transport): + """ asyncio.BaseProtocol: Called when a connection is made. """ + super().connection_made(transport) + + self.client_address = transport.get_extra_info('peername') + self.server.active_connections[self.client_address] = self + _logger.debug("TCP client connection established " + "[%s:%s]" % self.client_address[:2]) + + def connection_lost(self, exc): + """ + asyncio.BaseProtocol: Called when the connection is lost or closed. + """ + super().connection_lost(exc) + client_addr = self.client_address[:2] + _logger.debug("TCP client disconnected [%s:%s]" % client_addr) + if self.client_address in self.server.active_connections: + self.server.active_connections.pop(self.client_address) + + def data_received(self, data): + """ + asyncio.Protocol: (TCP) Called when some data is received. + data is a non-empty bytes object containing the incoming data. + """ + self.receive_queue.put_nowait(data) + + async def _recv_(self): + return await self.receive_queue.get() + + def _send_(self, data): + """ tcp send """ + self.transport.write(data) + + +class ModbusDisconnectedRequestHandler(ModbusBaseRequestHandler, + asyncio.DatagramProtocol): + """ Implements the modbus server protocol + + This uses the socketserver.BaseRequestHandler to implement + the client handler for a disconnected protocol (UDP). The + only difference is that we have to specify who to send the + resulting packet data to. + """ + def __init__(self,owner): + super().__init__(owner) + _future = asyncio.get_event_loop().create_future() + self.server.on_connection_terminated = _future + + def connection_lost(self,exc): + super().connection_lost(exc) + self.server.on_connection_terminated.set_result(True) + + def datagram_received(self,data, addr): + """ + asyncio.DatagramProtocol: Called when a datagram is received. + data is a bytes object containing the incoming data. addr + is the address of the peer sending the data; the exact + format depends on the transport. + """ + self.receive_queue.put_nowait((data, addr)) + + def error_received(self,exc): # pragma: no cover + """ + asyncio.DatagramProtocol: Called when a previous send + or receive operation raises an OSError. exc is the + OSError instance. + + This method is called in rare conditions, + when the transport (e.g. UDP) detects that a datagram could + not be delivered to its recipient. In many conditions + though, undeliverable datagrams will be silently dropped. + """ + _logger.error("datagram connection error [%s]" % exc) + + async def _recv_(self): + return await self.receive_queue.get() + + def _send_(self, data, addr): + self.transport.sendto(data, addr=addr) + + +class ModbusServerFactory: + """ + Builder class for a modbus server + + This also holds the server datastore so that it is persisted between connections + """ + + def __init__(self, store, framer=None, identity=None, **kwargs): + import warnings + warnings.warn("deprecated API for asyncio. ServerFactory's are a " + "twisted construct and don't have an equivalent in " + "asyncio", + DeprecationWarning) + + +class ModbusSingleRequestHandler(ModbusBaseRequestHandler, asyncio.Protocol): + """ Implements the modbus server protocol + This uses asyncio.Protocol to implement + the client handler for a serial connection. + """ + def connection_made(self, transport): + super().connection_made(transport) + + _logger.debug("Serial connection established") + + def connection_lost(self, exc): + super().connection_lost(exc) + _logger.debug("Serial conection lost") + if hasattr(self.server, 'on_connection_lost'): + self.server.on_connection_lost() + + def data_received(self, data): + self.receive_queue.put_nowait(data) + + async def _recv_(self): + return await self.receive_queue.get() + + def _send_(self, data): + if self.transport is not None: + self.transport.write(data) + +# --------------------------------------------------------------------------- # +# Server Implementations +# --------------------------------------------------------------------------- # + +class ModbusTcpServer: + """ + A modbus threaded tcp socket server + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__(self, + context, + framer=None, + identity=None, + address=None, + handler=None, + allow_reuse_address=False, + allow_reuse_port=False, + defer_start=False, + backlog=20, + loop=None, + **kwargs): + """ Overloaded initializer for the socket server + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param handler: A handler for each client session; default is + ModbusConnectedRequestHandler. The handler class + receives connection create/teardown events + :param allow_reuse_address: Whether the server will allow the + reuse of an address. + :param allow_reuse_port: Whether the server will allow the + reuse of a port. + :param backlog: is the maximum number of queued connections + passed to listen(). Defaults to 20, increase if many + connections are being made and broken to your Modbus slave + :param loop: optional asyncio event loop to run in. Will default to + asyncio.get_event_loop() supplied value if None. + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + :param response_manipulator: Callback method for manipulating the + response + """ + self.active_connections = {} + self.loop = loop or asyncio.get_event_loop() + self.allow_reuse_address = allow_reuse_address + self.decoder = ServerDecoder() + self.framer = framer or ModbusSocketFramer + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + self.address = address or ("", Defaults.Port) + self.handler = handler or ModbusConnectedRequestHandler + self.handler.server = self + self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + self.broadcast_enable = kwargs.get('broadcast_enable', + Defaults.broadcast_enable) + self.response_manipulator = kwargs.get("response_manipulator", None) + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + # asyncio future that will be done once server has started + self.serving = self.loop.create_future() + # constructors cannot be declared async, so we have to + # defer the initialization of the server + self.server = None + if PYTHON_VERSION >= (3, 7): + # start_serving is new in version 3.7 + self.server_factory = self.loop.create_server( + lambda: self.handler(self), + *self.address, + reuse_address=allow_reuse_address, + reuse_port=allow_reuse_port, + backlog=backlog, + start_serving=not defer_start + ) + else: + self.server_factory = self.loop.create_server( + lambda: self.handler(self), + *self.address, + reuse_address=allow_reuse_address, + reuse_port=allow_reuse_port, + backlog=backlog + ) + + async def serve_forever(self): + if self.server is None: + self.server = await self.server_factory + self.serving.set_result(True) + await self.server.serve_forever() + else: + raise RuntimeError("Can't call serve_forever on " + "an already running server object") + + def server_close(self): + for k, v in self.active_connections.items(): + _logger.warning("aborting active session {}".format(k)) + v.handler_task.cancel() + self.active_connections = {} + self.server.close() + + +class ModbusTlsServer(ModbusTcpServer): + """ + A modbus threaded tls socket server + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__(self, + context, + framer=None, + identity=None, + address=None, + sslctx=None, + certfile=None, + keyfile=None, + handler=None, + allow_reuse_address=False, + allow_reuse_port=False, + defer_start=False, + backlog=20, + loop=None, + **kwargs): + """ Overloaded initializer for the socket server + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param sslctx: The SSLContext to use for TLS (default None and auto + create) + :param certfile: The cert file path for TLS (used if sslctx is None) + :param keyfile: The key file path for TLS (used if sslctx is None) + :param handler: A handler for each client session; default is + ModbusConnectedRequestHandler. The handler class + receives connection create/teardown events + :param allow_reuse_address: Whether the server will allow the + reuse of an address. + :param allow_reuse_port: Whether the server will allow the + reuse of a port. + :param backlog: is the maximum number of queued connections + passed to listen(). Defaults to 20, increase if many + connections are being made and broken to your Modbus slave + :param loop: optional asyncio event loop to run in. Will default to + asyncio.get_event_loop() supplied value if None. + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + :param response_manipulator: Callback method for + manipulating the response + """ + self.active_connections = {} + self.loop = loop or asyncio.get_event_loop() + self.allow_reuse_address = allow_reuse_address + self.decoder = ServerDecoder() + self.framer = framer or ModbusTlsFramer + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + self.address = address or ("", Defaults.Port) + self.handler = handler or ModbusConnectedRequestHandler + self.handler.server = self + self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + self.broadcast_enable = kwargs.get('broadcast_enable', + Defaults.broadcast_enable) + self.response_manipulator = kwargs.get("response_manipulator", None) + + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + self.sslctx = sslctx + if self.sslctx is None: + self.sslctx = ssl.create_default_context() + self.sslctx.load_cert_chain(certfile=certfile, keyfile=keyfile) + # According to MODBUS/TCP Security Protocol Specification, it is + # TLSv2 at least + self.sslctx.options |= ssl.OP_NO_TLSv1_1 + self.sslctx.options |= ssl.OP_NO_TLSv1 + self.sslctx.options |= ssl.OP_NO_SSLv3 + self.sslctx.options |= ssl.OP_NO_SSLv2 + self.sslctx.verify_mode = ssl.CERT_OPTIONAL + self.sslctx.check_hostname = False + # asyncio future that will be done once server has started + self.serving = self.loop.create_future() + # constructors cannot be declared async, so we have to + # defer the initialization of the server + self.server = None + if PYTHON_VERSION >= (3, 7): + # start_serving is new in version 3.7 + self.server_factory = self.loop.create_server( + lambda: self.handler(self), + *self.address, + ssl=self.sslctx, + reuse_address=allow_reuse_address, + reuse_port=allow_reuse_port, + backlog=backlog, + start_serving=not defer_start + ) + else: + self.server_factory = self.loop.create_server( + lambda: self.handler(self), + *self.address, + ssl=self.sslctx, + reuse_address=allow_reuse_address, + reuse_port=allow_reuse_port, + backlog=backlog + ) + + +class ModbusUdpServer: + """ + A modbus threaded udp socket server + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__(self, context, framer=None, identity=None, address=None, + handler=None, allow_reuse_address=False, + allow_reuse_port=False, + defer_start=False, + backlog=20, + loop=None, + **kwargs): + """ Overloaded initializer for the socket server + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param handler: A handler for each client session; default is + ModbusDisonnectedRequestHandler + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + :param response_manipulator: Callback method for + manipulating the response + """ + self.loop = loop or asyncio.get_event_loop() + self.decoder = ServerDecoder() + self.framer = framer or ModbusSocketFramer + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + self.address = address or ("", Defaults.Port) + self.handler = handler or ModbusDisconnectedRequestHandler + self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + self.broadcast_enable = kwargs.get('broadcast_enable', + Defaults.broadcast_enable) + self.response_manipulator = kwargs.get("response_manipulator", None) + + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + self.protocol = None + self.endpoint = None + self.on_connection_terminated = None + self.stop_serving = self.loop.create_future() + # asyncio future that will be done once server has started + self.serving = self.loop.create_future() + self.server_factory = self.loop.create_datagram_endpoint( + lambda: self.handler(self), + local_addr=self.address, + reuse_address=allow_reuse_address, + reuse_port=allow_reuse_port, + allow_broadcast=True + ) + + async def serve_forever(self): + if self.protocol is None: + self.protocol, self.endpoint = await self.server_factory + self.serving.set_result(True) + await self.stop_serving + else: + raise RuntimeError("Can't call serve_forever on an " + "already running server object") + + def server_close(self): + self.stop_serving.set_result(True) + if self.endpoint.handler_task is not None: + self.endpoint.handler_task.cancel() + + self.protocol.close() + + +class ModbusSerialServer(object): + """ + A modbus threaded serial socket server + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + handler = None + + def __init__(self, context, framer=None, identity=None, **kwargs): # pragma: no cover + """ Overloaded initializer for the socket server + If the identity structure is not passed in, the ModbusControlBlock + uses its own empty structure. + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param port: The serial port to attach to + :param stopbits: The number of stop bits to use + :param bytesize: The bytesize of the serial messages + :param parity: Which kind of parity to use + :param baudrate: The baud rate to use for the serial device + :param timeout: The timeout to use for the serial device + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + :param autoreonnect: True to enable automatic reconnection, + False otherwise + :param reconnect_delay: reconnect delay in seconds + :param response_manipulator: Callback method for + manipulating the response + """ + self.device = kwargs.get('port', 0) + self.stopbits = kwargs.get('stopbits', Defaults.Stopbits) + self.bytesize = kwargs.get('bytesize', Defaults.Bytesize) + self.parity = kwargs.get('parity', Defaults.Parity) + self.baudrate = kwargs.get('baudrate', Defaults.Baudrate) + self.timeout = kwargs.get('timeout', Defaults.Timeout) + self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + self.broadcast_enable = kwargs.get('broadcast_enable', + Defaults.broadcast_enable) + self.auto_reconnect = kwargs.get('auto_reconnect', False) + self.reconnect_delay = kwargs.get('reconnect_delay', 2) + self.reconnecting_task = None + + self.handler = kwargs.get("handler") or ModbusSingleRequestHandler + self.framer = framer or ModbusRtuFramer + self.decoder = ServerDecoder() + self.context = context or ModbusServerContext() + self.response_manipulator = kwargs.get("response_manipulator", None) + self.control = ModbusControlBlock() + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + self.protocol = None + self.transport = None + + async def start(self): + await self._connect() + + def _protocol_factory(self): + return self.handler(self) + + async def _delayed_connect(self): + await asyncio.sleep(self.reconnect_delay) + await self._connect() + + async def _connect(self): + if self.reconnecting_task is not None: + self.reconnecting_task = None + + try: + self.transport, self.protocol = await create_serial_connection( + asyncio.get_event_loop(), + self._protocol_factory, + self.device, + baudrate=self.baudrate, + bytesize=self.bytesize, + parity=self.parity, + stopbits=self.stopbits, + timeout=self.timeout + ) + except serial.serialutil.SerialException as e: + _logger.debug("Failed to open serial port: {}".format(self.device)) + if not self.auto_reconnect: + raise e + + self._check_reconnect() + + except Exception as e: + _logger.debug("Exception while create - {}".format(e)) + + def on_connection_lost(self): + if self.transport is not None: + self.transport.close() + self.transport = None + self.protocol = None + + self._check_reconnect() + + def _check_reconnect(self): + _logger.debug("checkking autoreconnect {} {}".format( + self.auto_reconnect, self.reconnecting_task)) + if self.auto_reconnect and (self.reconnecting_task is None): + _logger.debug("Scheduling serial connection reconnect") + loop = asyncio.get_event_loop() + self.reconnecting_task = loop.create_task(self._delayed_connect()) + + async def serve_forever(self): + while True: + await asyncio.sleep(360) + + +# --------------------------------------------------------------------------- # +# Creation Factories +# --------------------------------------------------------------------------- # +async def StartTcpServer(context=None, identity=None, address=None, + custom_functions=[], defer_start=True, **kwargs): + """ A factory to start and run a tcp modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param defer_start: if set, a coroutine which can be started and stopped + will be returned. Otherwise, the server will be immediately spun + up without the ability to shut it off from within the asyncio loop + :param ignore_missing_slaves: True to not send errors on a request to a + missing slave + :return: an initialized but inactive server object coroutine + """ + framer = kwargs.pop("framer", ModbusSocketFramer) + server = ModbusTcpServer(context, framer, identity, address, **kwargs) + + for f in custom_functions: + server.decoder.register(f) # pragma: no cover + + if not defer_start: + await server.serve_forever() + + return server + + +async def StartTlsServer(context=None, identity=None, address=None, + sslctx=None, + certfile=None, keyfile=None, + allow_reuse_address=False, + allow_reuse_port=False, + custom_functions=[], + defer_start=True, **kwargs): + """ A factory to start and run a tls modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param sslctx: The SSLContext to use for TLS (default None and auto create) + :param certfile: The cert file path for TLS (used if sslctx is None) + :param keyfile: The key file path for TLS (used if sslctx is None) + :param allow_reuse_address: Whether the server will allow the reuse of an + address. + :param allow_reuse_port: Whether the server will allow the reuse of a port. + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param defer_start: if set, a coroutine which can be started and stopped + will be returned. Otherwise, the server will be immediately spun + up without the ability to shut it off from within the asyncio loop + :param ignore_missing_slaves: True to not send errors on a request to a + missing slave + :return: an initialized but inactive server object coroutine + """ + framer = kwargs.pop("framer", ModbusTlsFramer) + server = ModbusTlsServer(context, framer, identity, address, sslctx, + certfile, keyfile, + allow_reuse_address=allow_reuse_address, + allow_reuse_port=allow_reuse_port, **kwargs) + + for f in custom_functions: + server.decoder.register(f) # pragma: no cover + + if not defer_start: + await server.serve_forever() + + return server + + +async def StartUdpServer(context=None, identity=None, address=None, + custom_functions=[], defer_start=True, **kwargs): + """ A factory to start and run a udp modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param framer: The framer to operate with (default ModbusSocketFramer) + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + """ + framer = kwargs.pop('framer', ModbusSocketFramer) + server = ModbusUdpServer(context, framer, identity, address, **kwargs) + + for f in custom_functions: + server.decoder.register(f) # pragma: no cover + + if not defer_start: + await server.serve_forever() # pragma: no cover + + return server + + +async def StartSerialServer(context=None, identity=None, + custom_functions=[], **kwargs): # pragma: no cover + """ A factory to start and run a serial modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param framer: The framer to operate with (default ModbusAsciiFramer) + :param port: The serial port to attach to + :param stopbits: The number of stop bits to use + :param bytesize: The bytesize of the serial messages + :param parity: Which kind of parity to use + :param baudrate: The baud rate to use for the serial device + :param timeout: The timeout to use for the serial device + :param ignore_missing_slaves: True to not send errors on a request to a + missing slave + """ + framer = kwargs.pop('framer', ModbusAsciiFramer) + server = ModbusSerialServer(context, framer, identity, **kwargs) + for f in custom_functions: + server.decoder.register(f) + await server.start() + await server.serve_forever() + + +def StopServer(): + """ + Helper method to stop Async Server + """ + import warnings + warnings.warn("deprecated API for asyncio. Call server_close() on " + "server object returned by StartXxxServer", + DeprecationWarning) + + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + + +__all__ = [ + + "StartTcpServer", "StartTlsServer", "StartUdpServer", "StartSerialServer" + +] diff --git a/modbus/pymodbus/server/asynchronous.py b/modbus/pymodbus/server/asynchronous.py new file mode 100644 index 00000000..6ac991a1 --- /dev/null +++ b/modbus/pymodbus/server/asynchronous.py @@ -0,0 +1,363 @@ +""" +Implementation of a Twisted Modbus Server +------------------------------------------ + +""" +from binascii import b2a_hex +from twisted.internet import protocol +from twisted.internet.protocol import ServerFactory +from twisted.internet import reactor + +from pymodbus.constants import Defaults +from pymodbus.utilities import hexlify_packets +from pymodbus.factory import ServerDecoder +from pymodbus.datastore import ModbusServerContext +from pymodbus.device import ModbusControlBlock +from pymodbus.device import ModbusAccessControl +from pymodbus.device import ModbusDeviceIdentification +from pymodbus.exceptions import NoSuchSlaveException +from pymodbus.transaction import (ModbusSocketFramer, + ModbusRtuFramer, + ModbusAsciiFramer, + ModbusBinaryFramer) +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.internal.ptwisted import InstallManagementConsole +from pymodbus.compat import IS_PYTHON3 + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Modbus TCP Server +# --------------------------------------------------------------------------- # +class ModbusTcpProtocol(protocol.Protocol): + """ Implements a modbus server in twisted """ + + def connectionMade(self): + """ Callback for when a client connects + + ..note:: since the protocol factory cannot be accessed from the + protocol __init__, the client connection made is essentially + our __init__ method. + """ + _logger.debug("Client Connected [%s]" % self.transport.getHost()) + self.framer = self.factory.framer(decoder=self.factory.decoder, + client=None) + + def connectionLost(self, reason): + """ Callback for when a client disconnects + + :param reason: The client's reason for disconnecting + """ + _logger.debug("Client Disconnected: %s" % reason) + + def dataReceived(self, data): + """ Callback when we receive any data + + :param data: The data sent by the client + """ + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('Data Received: ' + hexlify_packets(data)) + if not self.factory.control.ListenOnly: + units = self.factory.store.slaves() + single = self.factory.store.single + self.framer.processIncomingPacket(data, self._execute, + single=single, + unit=units) + + def _execute(self, request): + """ Executes the request and returns the result + + :param request: The decoded request message + """ + try: + context = self.factory.store[request.unit_id] + response = request.execute(context) + except NoSuchSlaveException as ex: + _logger.debug("requested slave does not exist: %s" % request.unit_id ) + if self.factory.ignore_missing_slaves: + return # the client will simply timeout waiting for a response + response = request.doException(merror.GatewayNoResponse) + except Exception as ex: + _logger.debug("Datastore unable to fulfill request: %s" % ex) + response = request.doException(merror.SlaveFailure) + + response.transaction_id = request.transaction_id + response.unit_id = request.unit_id + self._send(response) + + def _send(self, message): + """ Send a request (string) to the network + + :param message: The unencoded modbus response + """ + if message.should_respond: + self.factory.control.Counter.BusMessage += 1 + pdu = self.framer.buildPacket(message) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('send: %s' % b2a_hex(pdu)) + return self.transport.write(pdu) + + +class ModbusServerFactory(ServerFactory): + """ + Builder class for a modbus server + + This also holds the server datastore so that it is + persisted between connections + """ + + protocol = ModbusTcpProtocol + + def __init__(self, store, framer=None, identity=None, **kwargs): + """ Overloaded initializer for the modbus factory + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param store: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param ignore_missing_slaves: True to not send errors on a request to a missing slave + """ + self.decoder = ServerDecoder() + self.framer = framer or ModbusSocketFramer + self.store = store or ModbusServerContext() + self.control = ModbusControlBlock() + self.access = ModbusAccessControl() + self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves', Defaults.IgnoreMissingSlaves) + + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + +# --------------------------------------------------------------------------- # +# Modbus UDP Server +# --------------------------------------------------------------------------- # +class ModbusUdpProtocol(protocol.DatagramProtocol): + """ Implements a modbus udp server in twisted """ + + def __init__(self, store, framer=None, identity=None, **kwargs): + """ Overloaded initializer for the modbus factory + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param store: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param ignore_missing_slaves: True to not send errors on a request to + a missing slave + """ + framer = framer or ModbusSocketFramer + self.decoder = ServerDecoder() + self.framer = framer(self.decoder) + self.store = store or ModbusServerContext() + self.control = ModbusControlBlock() + self.access = ModbusAccessControl() + self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + def datagramReceived(self, data, addr): + """ Callback when we receive any data + + :param data: The data sent by the client + """ + _logger.debug("Client Connected [%s]" % addr) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug("Datagram Received: "+ hexlify_packets(data)) + if not self.control.ListenOnly: + continuation = lambda request: self._execute(request, addr) + self.framer.processIncomingPacket(data, continuation) + + def _execute(self, request, addr): + """ Executes the request and returns the result + + :param request: The decoded request message + """ + try: + context = self.store[request.unit_id] + response = request.execute(context) + except NoSuchSlaveException as ex: + _logger.debug("requested slave does not exist: " + "%s" % request.unit_id ) + if self.ignore_missing_slaves: + return # the client will simply timeout waiting for a response + response = request.doException(merror.GatewayNoResponse) + except Exception as ex: + _logger.debug("Datastore unable to fulfill request: %s" % ex) + response = request.doException(merror.SlaveFailure) + #self.framer.populateResult(response) + response.transaction_id = request.transaction_id + response.unit_id = request.unit_id + self._send(response, addr) + + def _send(self, message, addr): + """ Send a request (string) to the network + + :param message: The unencoded modbus response + :param addr: The (host, port) to send the message to + """ + self.control.Counter.BusMessage += 1 + pdu = self.framer.buildPacket(message) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('send: %s' % b2a_hex(pdu)) + return self.transport.write(pdu, addr) + + +# --------------------------------------------------------------------------- # +# Starting Factories +# --------------------------------------------------------------------------- # +def _is_main_thread(): + import threading + + if IS_PYTHON3: + if threading.current_thread() != threading.main_thread(): + _logger.debug("Running in spawned thread") + return False + else: + if not isinstance(threading.current_thread(), threading._MainThread): + _logger.debug("Running in spawned thread") + return False + _logger.debug("Running in Main thread") + return True + + +def StartTcpServer(context, identity=None, address=None, + console=False, defer_reactor_run=False, custom_functions=[], + **kwargs): + """ + Helper method to start the Modbus Async TCP server + + :param context: The server data context + :param identify: The server identity to use (default empty) + :param address: An optional (interface, port) to bind to. + :param console: A flag indicating if you want the debug console + :param ignore_missing_slaves: True to not send errors on a request \ + to a missing slave + :param defer_reactor_run: True/False defer running reactor.run() as part \ + of starting server, to be explictly started by the user + :param custom_functions: An optional list of custom function classes + supported by server instance. + """ + from twisted.internet import reactor + + address = address or ("", Defaults.Port) + framer = kwargs.pop("framer", ModbusSocketFramer) + factory = ModbusServerFactory(context, framer, identity, **kwargs) + for f in custom_functions: + factory.decoder.register(f) + if console: + InstallManagementConsole({'factory': factory}) + + _logger.info("Starting Modbus TCP Server on %s:%s" % address) + reactor.listenTCP(address[1], factory, interface=address[0]) + if not defer_reactor_run: + reactor.run(installSignalHandlers=_is_main_thread()) + + +def StartUdpServer(context, identity=None, address=None, + defer_reactor_run=False, custom_functions=[], **kwargs): + """ + Helper method to start the Modbus Async Udp server + + :param context: The server data context + :param identify: The server identity to use (default empty) + :param address: An optional (interface, port) to bind to. + :param ignore_missing_slaves: True to not send errors on a request \ + to a missing slave + :param defer_reactor_run: True/False defer running reactor.run() as part \ + of starting server, to be explictly started by the user + :param custom_functions: An optional list of custom function classes + supported by server instance. + """ + from twisted.internet import reactor + + address = address or ("", Defaults.Port) + framer = kwargs.pop("framer", ModbusSocketFramer) + server = ModbusUdpProtocol(context, framer, identity, **kwargs) + for f in custom_functions: + server.decoder.register(f) + + _logger.info("Starting Modbus UDP Server on %s:%s" % address) + reactor.listenUDP(address[1], server, interface=address[0]) + if not defer_reactor_run: + reactor.run(installSignalHandlers=_is_main_thread()) + + +def StartSerialServer(context, identity=None, framer=ModbusAsciiFramer, + defer_reactor_run=False, custom_functions=[], **kwargs): + """ + Helper method to start the Modbus Async Serial server + + :param context: The server data context + :param identify: The server identity to use (default empty) + :param framer: The framer to use (default ModbusAsciiFramer) + :param port: The serial port to attach to + :param baudrate: The baud rate to use for the serial device + :param console: A flag indicating if you want the debug console + :param ignore_missing_slaves: True to not send errors on a request to a + missing slave + :param defer_reactor_run: True/False defer running reactor.run() as part + of starting server, to be explictly started by the user + :param custom_functions: An optional list of custom function classes + supported by server instance. + + """ + from twisted.internet import reactor + from twisted.internet.serialport import SerialPort + + port = kwargs.get('port', '/dev/ttyS0') + baudrate = kwargs.get('baudrate', Defaults.Baudrate) + console = kwargs.get('console', False) + bytesize = kwargs.get("bytesize", Defaults.Bytesize) + stopbits = kwargs.get("stopbits", Defaults.Stopbits) + parity = kwargs.get("parity", Defaults.Parity) + timeout = kwargs.get("timeout", 0) + xonxoff = kwargs.get("xonxoff", 0) + rtscts = kwargs.get("rtscts", 0) + + _logger.info("Starting Modbus Serial Server on %s" % port) + factory = ModbusServerFactory(context, framer, identity, **kwargs) + for f in custom_functions: + factory.decoder.register(f) + if console: + InstallManagementConsole({'factory': factory}) + if console: + InstallManagementConsole({'factory': factory}) + + protocol = factory.buildProtocol(None) + SerialPort.getHost = lambda self: port # hack for logging + SerialPort(protocol, port, reactor, baudrate=baudrate, parity=parity, + stopbits=stopbits, timeout=timeout, xonxoff=xonxoff, + rtscts=rtscts, bytesize=bytesize) + if not defer_reactor_run: + reactor.run(installSignalHandlers=_is_main_thread()) + + +def StopServer(): + """ + Helper method to stop Async Server + """ + from twisted.internet import reactor + if _is_main_thread(): + reactor.stop() + _logger.debug("Stopping server from main thread") + else: + reactor.callFromThread(reactor.stop) + _logger.debug("Stopping Server from another thread") + + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # +__all__ = [ + "StartTcpServer", "StartUdpServer", "StartSerialServer", "StopServer" +] diff --git a/modbus/pymodbus/server/reactive/__init__.py b/modbus/pymodbus/server/reactive/__init__.py new file mode 100644 index 00000000..bc4e3948 --- /dev/null +++ b/modbus/pymodbus/server/reactive/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright (c) 2020 by RiptideIO +All rights reserved. +""" diff --git a/modbus/pymodbus/server/reactive/default_config.json b/modbus/pymodbus/server/reactive/default_config.json new file mode 100644 index 00000000..6f076676 --- /dev/null +++ b/modbus/pymodbus/server/reactive/default_config.json @@ -0,0 +1,32 @@ +{ + "tcp": { + "handler": "ModbusConnectedRequestHandler", + "allow_reuse_address": true, + "allow_reuse_port": true, + "backlog": 20, + "ignore_missing_slaves": false + }, + "rtu": { + "handler": "ModbusSingleRequestHandler", + "stopbits": 1, + "bytesize": 8, + "parity": "N", + "baudrate": 9600, + "timeout": 3, + "auto_reconnect": false, + "reconnect_delay": 2 + }, + "tls": { + "handler": "ModbusConnectedRequestHandler", + "certfile": null, + "keyfile": null, + "allow_reuse_address": true, + "allow_reuse_port": true, + "backlog": 20, + "ignore_missing_slaves": false + }, + "udp": { + "handler": "ModbusDisonnectedRequestHandler", + "ignore_missing_slaves": false + } +} \ No newline at end of file diff --git a/modbus/pymodbus/server/reactive/default_config.py b/modbus/pymodbus/server/reactive/default_config.py new file mode 100644 index 00000000..ad274e8d --- /dev/null +++ b/modbus/pymodbus/server/reactive/default_config.py @@ -0,0 +1,37 @@ +""" +Copyright (c) 2020 by RiptideIO +All rights reserved. +""" + +DEFUALT_CONFIG = { + "tcp": { + "handler": "ModbusConnectedRequestHandler", + "allow_reuse_address": True, + "allow_reuse_port": True, + "backlog": 20, + "ignore_missing_slaves": False + }, + "serial": { + "handler": "ModbusSingleRequestHandler", + "stopbits": 1, + "bytesize": 8, + "parity": "N", + "baudrate": 9600, + "timeout": 3, + "auto_reconnect": False, + "reconnect_delay": 2 + }, + "tls": { + "handler": "ModbusConnectedRequestHandler", + "certfile": None, + "keyfile": None, + "allow_reuse_address": True, + "allow_reuse_port": True, + "backlog": 20, + "ignore_missing_slaves": False + }, + "udp": { + "handler": "ModbusDisonnectedRequestHandler", + "ignore_missing_slaves": False + } +} diff --git a/modbus/pymodbus/server/reactive/main.py b/modbus/pymodbus/server/reactive/main.py new file mode 100644 index 00000000..cf142257 --- /dev/null +++ b/modbus/pymodbus/server/reactive/main.py @@ -0,0 +1,372 @@ +""" +Copyright (c) 2020 by RiptideIO +All rights reserved. +""" +import os +import asyncio +import time +import random +import logging +from pymodbus.version import version as pymodbus_version +from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION +from pymodbus.pdu import ExceptionResponse, ModbusExceptions +from pymodbus.datastore.store import (ModbusSparseDataBlock, + ModbusSequentialDataBlock) +from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext +from pymodbus.device import ModbusDeviceIdentification + +if not IS_PYTHON3 or PYTHON_VERSION < (3, 6): + print(f"You are running {PYTHON_VERSION}." + "Reactive server requires python3.6 or above") + exit() + + +try: + from aiohttp import web +except ImportError as e: + print("Reactive server requires aiohttp. " + "Please install with 'pip install aiohttp' and try again.") + exit(1) + +from pymodbus.server.async_io import (ModbusTcpServer, + ModbusTlsServer, + ModbusSerialServer, + ModbusUdpServer, + ModbusSingleRequestHandler, + ModbusConnectedRequestHandler, + ModbusDisconnectedRequestHandler) +from pymodbus.transaction import (ModbusRtuFramer, + ModbusSocketFramer, + ModbusTlsFramer, + ModbusAsciiFramer, + ModbusBinaryFramer) +logger = logging.getLogger(__name__) + +SERVER_MAPPER = { + "tcp": ModbusTcpServer, + "serial": ModbusSerialServer, + "udp": ModbusUdpServer, + "tls": ModbusTlsServer +} + +DEFAULT_FRAMER = { + "tcp": ModbusSocketFramer, + "rtu": ModbusRtuFramer, + "tls": ModbusTlsFramer, + "udp": ModbusSocketFramer, + "ascii": ModbusAsciiFramer, + "binary": ModbusBinaryFramer +} + +DEFAULT_MANIPULATOR = { + "response_type": "normal", # normal, error, delayed, empty + "delay_by": 0, + "error_code": ModbusExceptions.IllegalAddress, + "clear_after": 5 # request count + +} +DEFUALT_HANDLERS = { + "ModbusSingleRequestHandler": ModbusSingleRequestHandler, + "ModbusConnectedRequestHandler": ModbusConnectedRequestHandler, + "ModbusDisconnectedRequestHandler": ModbusDisconnectedRequestHandler +} +DEFAULT_MODBUS_MAP = {"start_offset": 0, + "count": 100, + "value": 0, "sparse": False} +DEFAULT_DATA_BLOCK = { + "co": DEFAULT_MODBUS_MAP, + "di": DEFAULT_MODBUS_MAP, + "ir": DEFAULT_MODBUS_MAP, + "hr": DEFAULT_MODBUS_MAP + +} + +HINT = """ +Reactive Modbus Server started. +{} + +=========================================================================== +Example Usage: +curl -X POST http://{}:{} -d '{{"response_type": "error", "error_code": 4}}' +=========================================================================== +""" + + +class ReactiveServer: + """ + Modbus Asynchronous Server which can manipulate the response dynamically. + Useful for testing + """ + def __init__(self, host, port, modbus_server, loop=None): + self._web_app = web.Application() + self._runner = web.AppRunner(self._web_app) + self._host = host + self._port = int(port) + self._modbus_server = modbus_server + self._loop = loop + self._add_routes() + self._counter = 0 + self._modbus_server.response_manipulator = self.manipulate_response + self._manipulator_config = dict(**DEFAULT_MANIPULATOR) + self._web_app.on_startup.append(self.start_modbus_server) + self._web_app.on_shutdown.append(self.stop_modbus_server) + + @property + def web_app(self): + return self._web_app + + @property + def manipulator_config(self): + return self._manipulator_config + + @manipulator_config.setter + def manipulator_config(self, value): + if isinstance(value, dict): + self._manipulator_config.update(**value) + + def _add_routes(self): + self._web_app.add_routes([ + web.post('/', self._response_manipulator)]) + + async def start_modbus_server(self, app): + """ + Start Modbus server as asyncio task after startup + :param app: Webapp + :return: + """ + try: + if hasattr(asyncio, "create_task"): + if isinstance(self._modbus_server, ModbusSerialServer): + app["modbus_serial_server"] = asyncio.create_task( + self._modbus_server.start()) + app["modbus_server"] = asyncio.create_task( + self._modbus_server.serve_forever()) + else: + if isinstance(self._modbus_server, ModbusSerialServer): + app["modbus_serial_server"] = asyncio.ensure_future( + self._modbus_server.start() + ) + app["modbus_server"] = asyncio.ensure_future( + self._modbus_server.serve_forever()) + + logger.info("Modbus server started") + except Exception as e: + logger.error("Error starting modbus server") + logger.error(e) + + async def stop_modbus_server(self, app): + """ + Stop modbus server + :param app: Webapp + :return: + """ + logger.info("Stopping modbus server") + if isinstance(self._modbus_server, ModbusSerialServer): + app["modbus_serial_server"].cancel() + app["modbus_server"].cancel() + await app["modbus_server"] + logger.info("Modbus server Stopped") + + async def _response_manipulator(self, request): + """ + POST request Handler for response manipulation end point + Payload is a dict with following fields + :response_type : One among (normal, delayed, error, empty, stray) + :error_code: Modbus error code for error response + :delay_by: Delay sending response by seconds + + :param request: + :return: + """ + data = await request.json() + self._manipulator_config.update(data) + return web.json_response(data=data) + + def update_manipulator_config(self, config): + """ + Updates manipulator config. Resets previous counters + :param config: Manipulator config (dict) + :return: + """ + self._counter = 0 + self._manipulator_config = config + + def manipulate_response(self, response): + """ + Manipulates the actual response according to the required error state. + :param response: Modbus response object + :return: Modbus response + """ + skip_encoding = False + if not self._manipulator_config: + return response + else: + clear_after = self._manipulator_config.get("clear_after") + if clear_after and self._counter > clear_after: + logger.info("Resetting manipulator" + " after {} responses".format(clear_after)) + self.update_manipulator_config(dict(DEFAULT_MANIPULATOR)) + return response + response_type = self._manipulator_config.get("response_type") + if response_type == "error": + error_code = self._manipulator_config.get("error_code") + logger.warning( + "Sending error response for all incoming requests") + err_response = ExceptionResponse(response.function_code, error_code) + err_response.transaction_id = response.transaction_id + err_response.unit_id = response.unit_id + response = err_response + self._counter += 1 + elif response_type == "delayed": + delay_by = self._manipulator_config.get("delay_by") + logger.warning( + "Delaying response by {}s for " + "all incoming requests".format(delay_by)) + time.sleep(delay_by) + self._counter += 1 + elif response_type == "empty": + logger.warning("Sending empty response") + self._counter += 1 + response.should_respond = False + elif response_type == "stray": + data_len = self._manipulator_config.get("data_len", 10) + if data_len <= 0: + logger.warning(f"Invalid data_len {data_len}. " + f"Using default lenght 10") + data_len = 10 + response = os.urandom(data_len) + self._counter += 1 + skip_encoding = True + return response, skip_encoding + + def run(self): + """ + Run Web app + :return: + """ + def _info(message): + msg = HINT.format(message, self._host, self._port) + print(msg) + # print(message) + web.run_app(self._web_app, host=self._host, port=self._port, + print=_info) + + async def run_async(self): + """ + Run Web app + :return: + """ + try: + await self._runner.setup() + site = web.TCPSite(self._runner, self._host, self._port) + await site.start() + except Exception as e: + logger.error(e) + + @classmethod + def create_identity(cls, vendor="Pymodbus", product_code="PM", + vendor_url='http://github.com/riptideio/pymodbus/', + product_name="Pymodbus Server", + model_name="Reactive Server", + version=pymodbus_version.short()): + """ + Create modbus identity + :param vendor: + :param product_code: + :param vendor_url: + :param product_name: + :param model_name: + :param version: + :return: ModbusIdentity object + """ + identity = ModbusDeviceIdentification() + identity.VendorName = vendor + identity.ProductCode = product_code + identity.VendorUrl = vendor_url + identity.ProductName = product_name + identity.ModelName = model_name + identity.MajorMinorRevision = version + + return identity + + @classmethod + def create_context(cls, data_block=None, unit=1, + single=False): + """ + Create Modbus context. + :param data_block: Datablock (dict) Refer DEFAULT_DATA_BLOCK + :param unit: Unit id for the slave + :param single: To run as a single slave + :return: ModbusServerContext object + """ + block = dict() + data_block = data_block or DEFAULT_DATA_BLOCK + for modbus_entity, block_desc in data_block.items(): + start_address = block_desc.get("start_address", 0) + default_count = block_desc.get("count", 0) + default_value = block_desc.get("value", 0) + default_values = [default_value]*default_count + sparse = block_desc.get("sparse", False) + db = ModbusSequentialDataBlock if not sparse else ModbusSparseDataBlock + if sparse: + address_map = block_desc.get("address_map") + if not address_map: + address_map = random.sample( + range(start_address+1, default_count), default_count-1) + address_map.insert(0, 0) + block[modbus_entity] = {add: val for add in sorted(address_map) for val in default_values} + else: + block[modbus_entity] =db(start_address, default_values) + + slave_context = ModbusSlaveContext(**block, zero_mode=True) + if not single: + slaves = {} + for i in unit: + slaves[i] = slave_context + else: + slaves = slave_context + server_context = ModbusServerContext(slaves, single=single) + return server_context + + @classmethod + def factory(cls, server, framer=None, context=None, unit=1, single=False, + host="localhost", modbus_port=5020, web_port=8080, + data_block=DEFAULT_DATA_BLOCK, identity=None, loop=None, **kwargs): + """ + Factory to create ReactiveModbusServer + :param server: Modbus server type (tcp, rtu, tls, udp) + :param framer: Modbus framer (ModbusSocketFramer, ModbusRTUFramer, ModbusTLSFramer) + :param context: Modbus server context to use + :param unit: Modbus unit id + :param single: Run in single mode + :param host: Host address to use for both web app and modbus server (default localhost) + :param modbus_port: Modbus port for TCP and UDP server(default: 5020) + :param web_port: Web App port (default: 8080) + :param data_block: Datablock (refer DEFAULT_DATA_BLOCK) + :param identity: Modbus identity object + :param loop: Asyncio loop to use + :param kwargs: Other server specific keyword arguments, refer corresponding servers documentation + :return: ReactiveServer object + """ + if server.lower() not in SERVER_MAPPER: + logger.error(f"Invalid server {server}", server) + exit(1) + server = SERVER_MAPPER.get(server) + if not framer: + framer = DEFAULT_FRAMER.get(server) + if not context: + context = cls.create_context(data_block=data_block, + unit=unit, single=single) + if not identity: + identity = cls.create_identity() + if server == ModbusSerialServer: + kwargs["port"] = modbus_port + server = server(context, framer=framer, identity=identity, + **kwargs) + else: + server = server(context, framer=framer, identity=identity, + address=(host, modbus_port), defer_start=False, + **kwargs) + return ReactiveServer(host, web_port, server, loop) + +# __END__ diff --git a/modbus/pymodbus/server/sync.py b/modbus/pymodbus/server/sync.py new file mode 100644 index 00000000..041069aa --- /dev/null +++ b/modbus/pymodbus/server/sync.py @@ -0,0 +1,703 @@ +""" +Implementation of a Threaded Modbus Server +------------------------------------------ + +""" +from binascii import b2a_hex +import serial +import socket +import ssl +import traceback + +from pymodbus.constants import Defaults +from pymodbus.utilities import hexlify_packets +from pymodbus.factory import ServerDecoder +from pymodbus.datastore import ModbusServerContext +from pymodbus.device import ModbusControlBlock +from pymodbus.device import ModbusDeviceIdentification +from pymodbus.transaction import * +from pymodbus.exceptions import NotImplementedException, NoSuchSlaveException +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.compat import socketserver, byte2int + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Protocol Handlers +# --------------------------------------------------------------------------- # + +class ModbusBaseRequestHandler(socketserver.BaseRequestHandler): + """ Implements the modbus server protocol + + This uses the socketserver.BaseRequestHandler to implement + the client handler. + """ + running = False + framer = None + + def setup(self): + """ Callback for when a client connects + """ + _logger.debug("Client Connected [%s:%s]" % self.client_address) + self.running = True + self.framer = self.server.framer(self.server.decoder, client=None) + self.server.threads.append(self) + + def finish(self): + """ Callback for when a client disconnects + """ + _logger.debug("Client Disconnected [%s:%s]" % self.client_address) + self.server.threads.remove(self) + + def execute(self, request): + """ The callback to call with the resulting message + + :param request: The decoded request message + """ + broadcast = False + try: + if self.server.broadcast_enable and request.unit_id == 0: + broadcast = True + # if broadcasting then execute on all slave contexts, note response will be ignored + for unit_id in self.server.context.slaves(): + response = request.execute(self.server.context[unit_id]) + else: + context = self.server.context[request.unit_id] + response = request.execute(context) + except NoSuchSlaveException as ex: + _logger.debug("requested slave does " + "not exist: %s" % request.unit_id ) + if self.server.ignore_missing_slaves: + return # the client will simply timeout waiting for a response + response = request.doException(merror.GatewayNoResponse) + except Exception as ex: + _logger.debug("Datastore unable to fulfill request: " + "%s; %s", ex, traceback.format_exc()) + response = request.doException(merror.SlaveFailure) + # no response when broadcasting + if not broadcast: + response.transaction_id = request.transaction_id + response.unit_id = request.unit_id + self.send(response) + + # ----------------------------------------------------------------------- # + # Base class implementations + # ----------------------------------------------------------------------- # + def handle(self): + """ Callback when we receive any data + """ + raise NotImplementedException("Method not implemented" + " by derived class") + + def send(self, message): + """ Send a request (string) to the network + + :param message: The unencoded modbus response + """ + raise NotImplementedException("Method not implemented " + "by derived class") + + +class ModbusSingleRequestHandler(ModbusBaseRequestHandler): + """ Implements the modbus server protocol + + This uses the socketserver.BaseRequestHandler to implement + the client handler for a single client(serial clients) + """ + def handle(self): + """ Callback when we receive any data + """ + while self.running: + try: + data = self.request.recv(1024) + if data: + units = self.server.context.slaves() + if not isinstance(units, (list, tuple)): + units = [units] + # if broadcast is enabled make sure to process requests to address 0 + if self.server.broadcast_enable: + if 0 not in units: + units.append(0) + single = self.server.context.single + self.framer.processIncomingPacket(data, self.execute, + units, single=single) + except Exception as msg: + # Since we only have a single socket, we cannot exit + # Clear frame buffer + self.framer.resetFrame() + _logger.debug("Error: Socket error occurred %s" % msg) + + def send(self, message): + """ Send a request (string) to the network + + :param message: The unencoded modbus response + """ + if message.should_respond: + # self.server.control.Counter.BusMessage += 1 + pdu = self.framer.buildPacket(message) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('send: [%s]- %s' % (message, b2a_hex(pdu))) + return self.request.send(pdu) + + +class CustomSingleRequestHandler(ModbusSingleRequestHandler): + + def __init__(self, request, client_address, server): + self.request = request + self.client_address = client_address + self.server = server + self.running = True + self.setup() + + +class ModbusConnectedRequestHandler(ModbusBaseRequestHandler): + """ Implements the modbus server protocol + + This uses the socketserver.BaseRequestHandler to implement + the client handler for a connected protocol (TCP). + """ + + def handle(self): + """Callback when we receive any data, until self.running becomes False. + Blocks indefinitely awaiting data. If shutdown is required, then the + global socket.settimeout() may be used, to allow timely + checking of self.running. However, since this also affects socket + connects, if there are outgoing socket connections used in the same + program, then these will be prevented, if the specfied timeout is too + short. Hence, this is unreliable. + + To respond to Modbus...Server.server_close() (which clears each + handler's self.running), derive from this class to provide an + alternative handler that awakens from time to time when no input is + available and checks self.running. + Use Modbus...Server( handler=... ) keyword to supply the alternative + request handler class. + + """ + reset_frame = False + while self.running: + try: + units = self.server.context.slaves() + data = self.request.recv(1024) + if not data: + self.running = False + else: + if not isinstance(units, (list, tuple)): + units = [units] + # if broadcast is enabled make sure to + # process requests to address 0 + if self.server.broadcast_enable: + if 0 not in units: + units.append(0) + + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('Handling data: ' + hexlify_packets(data)) + single = self.server.context.single + self.framer.processIncomingPacket(data, self.execute, units, + single=single) + except socket.timeout as msg: + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug("Socket timeout occurred %s", msg) + reset_frame = True + except socket.error as msg: + _logger.error("Socket error occurred %s" % msg) + self.running = False + except: + _logger.error("Socket exception occurred " + "%s" % traceback.format_exc() ) + self.running = False + reset_frame = True + finally: + if reset_frame: + self.framer.resetFrame() + reset_frame = False + + def send(self, message): + """ Send a request (string) to the network + + :param message: The unencoded modbus response + """ + if message.should_respond: + # self.server.control.Counter.BusMessage += 1 + pdu = self.framer.buildPacket(message) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('send: [%s]- %s' % (message, b2a_hex(pdu))) + return self.request.send(pdu) + + +class ModbusDisconnectedRequestHandler(ModbusBaseRequestHandler): + """ Implements the modbus server protocol + + This uses the socketserver.BaseRequestHandler to implement + the client handler for a disconnected protocol (UDP). The + only difference is that we have to specify who to send the + resulting packet data to. + """ + socket = None + + def handle(self): + """ Callback when we receive any data + """ + reset_frame = False + while self.running: + try: + data, self.socket = self.request + if not data: + self.running = False + data = b'' + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('Handling data: ' + hexlify_packets(data)) + # if not self.server.control.ListenOnly: + units = self.server.context.slaves() + single = self.server.context.single + self.framer.processIncomingPacket(data, self.execute, + units, single=single) + except socket.timeout: pass + except socket.error as msg: + _logger.error("Socket error occurred %s" % msg) + self.running = False + reset_frame = True + except Exception as msg: + _logger.error(msg) + self.running = False + reset_frame = True + finally: + # Reset data after processing + self.request = (None, self.socket) + if reset_frame: + self.framer.resetFrame() + reset_frame = False + + def send(self, message): + """ Send a request (string) to the network + + :param message: The unencoded modbus response + """ + if message.should_respond: + #self.server.control.Counter.BusMessage += 1 + pdu = self.framer.buildPacket(message) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug('send: [%s]- %s' % (message, b2a_hex(pdu))) + return self.socket.sendto(pdu, self.client_address) + + +# --------------------------------------------------------------------------- # +# Server Implementations +# --------------------------------------------------------------------------- # +class ModbusTcpServer(socketserver.ThreadingTCPServer): + """ + A modbus threaded tcp socket server + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__(self, context, framer=None, identity=None, + address=None, handler=None, allow_reuse_address=False, + **kwargs): + """ Overloaded initializer for the socket server + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param handler: A handler for each client session; default is + ModbusConnectedRequestHandler + :param allow_reuse_address: Whether the server will allow the + reuse of an address. + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + """ + self.threads = [] + self.allow_reuse_address = allow_reuse_address + self.decoder = ServerDecoder() + self.framer = framer or ModbusSocketFramer + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + self.address = address or ("", Defaults.Port) + self.handler = handler or ModbusConnectedRequestHandler + self.ignore_missing_slaves = kwargs.pop('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + self.broadcast_enable = kwargs.pop('broadcast_enable', + Defaults.broadcast_enable) + + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + socketserver.ThreadingTCPServer.__init__(self, self.address, + self.handler, + **kwargs) + + def process_request(self, request, client): + """ Callback for connecting a new client thread + + :param request: The request to handle + :param client: The address of the client + """ + _logger.debug("Started thread to serve client at " + str(client)) + socketserver.ThreadingTCPServer.process_request(self, request, client) + + def shutdown(self): + """ Stops the serve_forever loop. + + Overridden to signal handlers to stop. + """ + for thread in self.threads: + thread.running = False + socketserver.ThreadingTCPServer.shutdown(self) + + def server_close(self): + """ Callback for stopping the running server + """ + _logger.debug("Modbus server stopped") + self.socket.close() + for thread in self.threads: + thread.running = False + + +class ModbusTlsServer(ModbusTcpServer): + """ + A modbus threaded TLS server + + We inherit and overload the ModbusTcpServer so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__(self, context, framer=None, identity=None, + address=None, handler=None, allow_reuse_address=False, + sslctx=None, certfile=None, keyfile=None, **kwargs): + """ Overloaded initializer for the ModbusTcpServer + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param handler: A handler for each client session; default is + ModbusConnectedRequestHandler + :param allow_reuse_address: Whether the server will allow the + reuse of an address. + :param sslctx: The SSLContext to use for TLS (default None and auto + create) + :param certfile: The cert file path for TLS (used if sslctx is None) + :param keyfile: The key file path for TLS (used if sslctx is None) + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + """ + self.sslctx = sslctx + if self.sslctx is None: + self.sslctx = ssl.create_default_context() + self.sslctx.load_cert_chain(certfile=certfile, keyfile=keyfile) + # According to MODBUS/TCP Security Protocol Specification, it is + # TLSv2 at least + self.sslctx.options |= ssl.OP_NO_TLSv1_1 + self.sslctx.options |= ssl.OP_NO_TLSv1 + self.sslctx.options |= ssl.OP_NO_SSLv3 + self.sslctx.options |= ssl.OP_NO_SSLv2 + self.sslctx.verify_mode = ssl.CERT_OPTIONAL + self.sslctx.check_hostname = False + + ModbusTcpServer.__init__(self, context, framer, identity, address, + handler, allow_reuse_address, **kwargs) + + def server_activate(self): + """ Callback for starting listening over TLS connection + """ + self.socket = self.sslctx.wrap_socket(self.socket, server_side=True) + socketserver.ThreadingTCPServer.server_activate(self) + + +class ModbusUdpServer(socketserver.ThreadingUDPServer): + """ + A modbus threaded udp socket server + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__(self, context, framer=None, identity=None, address=None, + handler=None, **kwargs): + """ Overloaded initializer for the socket server + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param handler: A handler for each client session; default is + ModbusDisonnectedRequestHandler + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + """ + self.threads = [] + self.decoder = ServerDecoder() + self.framer = framer or ModbusSocketFramer + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + self.address = address or ("", Defaults.Port) + self.handler = handler or ModbusDisconnectedRequestHandler + self.ignore_missing_slaves = kwargs.pop('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + self.broadcast_enable = kwargs.pop('broadcast_enable', + Defaults.broadcast_enable) + + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + socketserver.ThreadingUDPServer.__init__(self, + self.address, self.handler, **kwargs) + # self._BaseServer__shutdown_request = True + + def process_request(self, request, client): + """ Callback for connecting a new client thread + + :param request: The request to handle + :param client: The address of the client + """ + packet, socket = request # TODO I might have to rewrite + _logger.debug("Started thread to serve client at " + str(client)) + socketserver.ThreadingUDPServer.process_request(self, request, client) + + def server_close(self): + """ Callback for stopping the running server + """ + _logger.debug("Modbus server stopped") + self.socket.close() + for thread in self.threads: + thread.running = False + + +class ModbusSerialServer(object): + """ + A modbus threaded serial socket server + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + handler = None + + def __init__(self, context, framer=None, identity=None, **kwargs): + """ Overloaded initializer for the socket server + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param port: The serial port to attach to + :param stopbits: The number of stop bits to use + :param bytesize: The bytesize of the serial messages + :param parity: Which kind of parity to use + :param baudrate: The baud rate to use for the serial device + :param timeout: The timeout to use for the serial device + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat unit_id 0 as broadcast address, + False to treat 0 as any other unit_id + """ + self.threads = [] + self.decoder = ServerDecoder() + self.framer = framer or ModbusAsciiFramer + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + self.device = kwargs.get('port', 0) + self.stopbits = kwargs.get('stopbits', Defaults.Stopbits) + self.bytesize = kwargs.get('bytesize', Defaults.Bytesize) + self.parity = kwargs.get('parity', Defaults.Parity) + self.baudrate = kwargs.get('baudrate', Defaults.Baudrate) + self.timeout = kwargs.get('timeout', Defaults.Timeout) + self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves', + Defaults.IgnoreMissingSlaves) + self.broadcast_enable = kwargs.get('broadcast_enable', + Defaults.broadcast_enable) + self.socket = None + if self._connect(): + self.is_running = True + self._build_handler() + + def _connect(self): + """ Connect to the serial server + + :returns: True if connection succeeded, False otherwise + """ + if self.socket: return True + try: + self.socket = serial.Serial(port=self.device, + timeout=self.timeout, + bytesize=self.bytesize, + stopbits=self.stopbits, + baudrate=self.baudrate, + parity=self.parity) + except serial.SerialException as msg: + _logger.error(msg) + return self.socket is not None + + def _build_handler(self): + """ A helper method to create and monkeypatch + a serial handler. + + :returns: A patched handler + """ + + request = self.socket + request.send = request.write + request.recv = request.read + self.handler = CustomSingleRequestHandler(request, + (self.device, self.device), + self) + + def serve_forever(self): + """ Callback for connecting a new client thread + """ + if self._connect(): + _logger.debug("Started thread to serve client") + if not self.handler: + self._build_handler() + while self.is_running: + if hasattr(self.handler, "response_manipulator"): + self.handler.response_manipulator() + else: + self.handler.handle() + else: + _logger.error("Error opening serial port , " + "Unable to start server!!") + + def server_close(self): + """ Callback for stopping the running server + """ + _logger.debug("Modbus server stopped") + self.is_running = False + self.handler.finish() + self.handler.running = False + self.handler = None + self.socket.close() + + +# --------------------------------------------------------------------------- # +# Creation Factories +# --------------------------------------------------------------------------- # +def StartTcpServer(context=None, identity=None, address=None, + custom_functions=[], **kwargs): + """ A factory to start and run a tcp modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param ignore_missing_slaves: True to not send errors on a request to a + missing slave + """ + framer = kwargs.pop("framer", ModbusSocketFramer) + server = ModbusTcpServer(context, framer, identity, address, **kwargs) + + for f in custom_functions: + server.decoder.register(f) + server.serve_forever() + + +def StartTlsServer(context=None, identity=None, address=None, sslctx=None, + certfile=None, keyfile=None, custom_functions=[], **kwargs): + """ A factory to start and run a tls modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param sslctx: The SSLContext to use for TLS (default None and auto create) + :param certfile: The cert file path for TLS (used if sslctx is None) + :param keyfile: The key file path for TLS (used if sslctx is None) + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param ignore_missing_slaves: True to not send errors on a request to a + missing slave + """ + framer = kwargs.pop("framer", ModbusTlsFramer) + server = ModbusTlsServer(context, framer, identity, address, sslctx=sslctx, + certfile=certfile, keyfile=keyfile, **kwargs) + + for f in custom_functions: + server.decoder.register(f) + server.serve_forever() + + +def StartUdpServer(context=None, identity=None, address=None, + custom_functions=[], **kwargs): + """ A factory to start and run a udp modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param framer: The framer to operate with (default ModbusSocketFramer) + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + """ + framer = kwargs.pop('framer', ModbusSocketFramer) + server = ModbusUdpServer(context, framer, identity, address, **kwargs) + for f in custom_functions: + server.decoder.register(f) + server.serve_forever() + + +def StartSerialServer(context=None, identity=None, custom_functions=[], + **kwargs): + """ A factory to start and run a serial modbus server + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param framer: The framer to operate with (default ModbusAsciiFramer) + :param port: The serial port to attach to + :param stopbits: The number of stop bits to use + :param bytesize: The bytesize of the serial messages + :param parity: Which kind of parity to use + :param baudrate: The baud rate to use for the serial device + :param timeout: The timeout to use for the serial device + :param ignore_missing_slaves: True to not send errors on a request to a + missing slave + """ + framer = kwargs.pop('framer', ModbusAsciiFramer) + server = ModbusSerialServer(context, framer, identity, **kwargs) + for f in custom_functions: + server.decoder.register(f) + server.serve_forever() + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + + +__all__ = [ + "StartTcpServer", "StartTlsServer", "StartUdpServer", "StartSerialServer" +] + diff --git a/modbus/pymodbus/transaction.py b/modbus/pymodbus/transaction.py new file mode 100644 index 00000000..e9f8c68c --- /dev/null +++ b/modbus/pymodbus/transaction.py @@ -0,0 +1,539 @@ +""" +Collection of transaction based abstractions + +""" + +import struct +import socket +import time +from threading import RLock +from functools import partial + +from pymodbus.exceptions import ModbusIOException, NotImplementedException +from pymodbus.exceptions import InvalidMessageReceivedException +from pymodbus.constants import Defaults +from pymodbus.framer.ascii_framer import ModbusAsciiFramer +from pymodbus.framer.rtu_framer import ModbusRtuFramer +from pymodbus.framer.socket_framer import ModbusSocketFramer +from pymodbus.framer.tls_framer import ModbusTlsFramer +from pymodbus.framer.binary_framer import ModbusBinaryFramer +from pymodbus.utilities import hexlify_packets, ModbusTransactionState +from pymodbus.compat import iterkeys, byte2int + + +# Python 2 compatibility. +try: + TimeoutError +except NameError: + TimeoutError = socket.timeout + + +# --------------------------------------------------------------------------- # +# Logging +# --------------------------------------------------------------------------- # +import logging +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# The Global Transaction Manager +# --------------------------------------------------------------------------- # +class ModbusTransactionManager(object): + """ Implements a transaction for a manager + + The transaction protocol can be represented by the following pseudo code:: + + count = 0 + do + result = send(message) + if (timeout or result == bad) + count++ + else break + while (count < 3) + + This module helps to abstract this away from the framer and protocol. + """ + + def __init__(self, client, **kwargs): + """ Initializes an instance of the ModbusTransactionManager + + :param client: The client socket wrapper + :param retry_on_empty: Should the client retry on empty + :param retries: The number of retries to allow + """ + self.tid = Defaults.TransactionId + self.client = client + self.backoff = kwargs.get('backoff', Defaults.Backoff) or 0.3 + self.retry_on_empty = kwargs.get('retry_on_empty', + Defaults.RetryOnEmpty) + self.retry_on_invalid = kwargs.get('retry_on_invalid', + Defaults.RetryOnInvalid) + self.retries = kwargs.get('retries', Defaults.Retries) or 1 + self.reset_socket = kwargs.get('reset_socket', True) + self._transaction_lock = RLock() + self._no_response_devices = [] + if client: + self._set_adu_size() + + def _set_adu_size(self): + # base ADU size of modbus frame in bytes + if isinstance(self.client.framer, ModbusSocketFramer): + self.base_adu_size = 7 # tid(2), pid(2), length(2), uid(1) + elif isinstance(self.client.framer, ModbusRtuFramer): + self.base_adu_size = 3 # address(1), CRC(2) + elif isinstance(self.client.framer, ModbusAsciiFramer): + self.base_adu_size = 7 # start(1)+ Address(2), LRC(2) + end(2) + elif isinstance(self.client.framer, ModbusBinaryFramer): + self.base_adu_size = 5 # start(1) + Address(1), CRC(2) + end(1) + elif isinstance(self.client.framer, ModbusTlsFramer): + self.base_adu_size = 0 # no header and footer + else: + self.base_adu_size = -1 + + def _calculate_response_length(self, expected_pdu_size): + if self.base_adu_size == -1: + return None + else: + return self.base_adu_size + expected_pdu_size + + def _calculate_exception_length(self): + """ Returns the length of the Modbus Exception Response according to + the type of Framer. + """ + if isinstance(self.client.framer, (ModbusSocketFramer, + ModbusTlsFramer)): + return self.base_adu_size + 2 # Fcode(1), ExcecptionCode(1) + elif isinstance(self.client.framer, ModbusAsciiFramer): + return self.base_adu_size + 4 # Fcode(2), ExcecptionCode(2) + elif isinstance(self.client.framer, (ModbusRtuFramer, + ModbusBinaryFramer)): + return self.base_adu_size + 2 # Fcode(1), ExcecptionCode(1) + + return None + + def _validate_response(self, request, response, exp_resp_len): + """ + Validate Incoming response against request + :param request: Request sent + :param response: Response received + :param exp_resp_len: Expected response length + :return: New transactions state + """ + if not response: + return False + + mbap = self.client.framer.decode_data(response) + if mbap.get('unit') != request.unit_id or mbap.get('fcode') & 0x7F != request.function_code: + return False + + if 'length' in mbap and exp_resp_len: + return mbap.get('length') == exp_resp_len + return True + + def execute(self, request): + """ Starts the producer to send the next request to + consumer.write(Frame(request)) + """ + with self._transaction_lock: + try: + _logger.debug("Current transaction state - {}".format( + ModbusTransactionState.to_string(self.client.state)) + ) + retries = self.retries + request.transaction_id = self.getNextTID() + _logger.debug("Running transaction " + "{}".format(request.transaction_id)) + _buffer = hexlify_packets(self.client.framer._buffer) + if _buffer: + _logger.debug("Clearing current Frame " + ": - {}".format(_buffer)) + self.client.framer.resetFrame() + broadcast = (self.client.broadcast_enable + and request.unit_id == 0) + if broadcast: + self._transact(request, None, broadcast=True) + response = b'Broadcast write sent - no response expected' + else: + expected_response_length = None + if not isinstance(self.client.framer, ModbusSocketFramer): + if hasattr(request, "get_response_pdu_size"): + response_pdu_size = request.get_response_pdu_size() + if isinstance(self.client.framer, ModbusAsciiFramer): + response_pdu_size = response_pdu_size * 2 + if response_pdu_size: + expected_response_length = self._calculate_response_length(response_pdu_size) + if request.unit_id in self._no_response_devices: + full = True + else: + full = False + c_str = str(self.client) + if "modbusudpclient" in c_str.lower().strip(): + full = True + if not expected_response_length: + expected_response_length = Defaults.ReadSize + response, last_exception = self._transact( + request, + expected_response_length, + full=full, + broadcast=broadcast + ) + while retries > 0: + valid_response = self._validate_response( + request, response, expected_response_length + ) + if valid_response: + if request.unit_id in self._no_response_devices and response: + self._no_response_devices.remove(request.unit_id) + _logger.debug("Got response!!!") + break + else: + if not response: + if request.unit_id not in self._no_response_devices: + self._no_response_devices.append(request.unit_id) + if self.retry_on_empty: + response, last_exception = self._retry_transaction(retries, "empty", request, expected_response_length, full=full) + retries -= 1 + else: + # No response received and retries not enabled + break + else: + if self.retry_on_invalid: + response, last_exception = self._retry_transaction(retries, "invalid", request, expected_response_length, full=full) + retries -= 1 + else: + break + # full = False + addTransaction = partial(self.addTransaction, + tid=request.transaction_id) + self.client.framer.processIncomingPacket(response, + addTransaction, + request.unit_id) + response = self.getTransaction(request.transaction_id) + if not response: + if len(self.transactions): + response = self.getTransaction(tid=0) + else: + last_exception = last_exception or ( + "No Response received from the remote unit" + "/Unable to decode response") + response = ModbusIOException(last_exception, + request.function_code) + if self.reset_socket: + self.client.close() + if hasattr(self.client, "state"): + _logger.debug("Changing transaction state from " + "'PROCESSING REPLY' to " + "'TRANSACTION_COMPLETE'") + self.client.state = ( + ModbusTransactionState.TRANSACTION_COMPLETE) + + return response + except ModbusIOException as ex: + # Handle decode errors in processIncomingPacket method + _logger.exception(ex) + self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE + if self.reset_socket: + self.client.close() + return ex + + def _retry_transaction(self, retries, reason, + packet, response_length, full=False): + _logger.debug("Retry on {} response - {}".format(reason, retries)) + _logger.debug("Changing transaction state from " + "'WAITING_FOR_REPLY' to 'RETRYING'") + self.client.state = ModbusTransactionState.RETRYING + if self.backoff: + delay = 2 ** (self.retries - retries) * self.backoff + time.sleep(delay) + _logger.debug("Sleeping {}".format(delay)) + self.client.connect() + if hasattr(self.client, "_in_waiting"): + in_waiting = self.client._in_waiting() + if in_waiting: + if response_length == in_waiting: + result = self._recv(response_length, full) + return result, None + return self._transact(packet, response_length, full=full) + + def _transact(self, packet, response_length, + full=False, broadcast=False): + """ + Does a Write and Read transaction + :param packet: packet to be sent + :param response_length: Expected response length + :param full: the target device was notorious for its no response. Dont + waste time this time by partial querying + :return: response + """ + last_exception = None + try: + self.client.connect() + packet = self.client.framer.buildPacket(packet) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug("SEND: " + hexlify_packets(packet)) + size = self._send(packet) + if isinstance(size, bytes) and self.client.state == ModbusTransactionState.RETRYING: + _logger.debug("Changing transaction state from " + "'RETRYING' to 'PROCESSING REPLY'") + self.client.state = ModbusTransactionState.PROCESSING_REPLY + return size, None + if broadcast: + if size: + _logger.debug("Changing transaction state from 'SENDING' " + "to 'TRANSACTION_COMPLETE'") + self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE + return b'', None + if size: + _logger.debug("Changing transaction state from 'SENDING' " + "to 'WAITING FOR REPLY'") + self.client.state = ModbusTransactionState.WAITING_FOR_REPLY + if hasattr(self.client, "handle_local_echo") and self.client.handle_local_echo is True: + local_echo_packet = self._recv(size, full) + if local_echo_packet != packet: + return b'', "Wrong local echo" + result = self._recv(response_length, full) + # result2 = self._recv(response_length, full) + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug("RECV: " + hexlify_packets(result)) + + except (socket.error, ModbusIOException, + InvalidMessageReceivedException) as msg: + if self.reset_socket: + self.client.close() + _logger.debug("Transaction failed. (%s) " % msg) + last_exception = msg + result = b'' + return result, last_exception + + def _send(self, packet, retrying=False): + return self.client.framer.sendPacket(packet) + + def _recv(self, expected_response_length, full): + total = None + if not full: + exception_length = self._calculate_exception_length() + if isinstance(self.client.framer, ModbusSocketFramer): + min_size = 8 + elif isinstance(self.client.framer, ModbusRtuFramer): + min_size = 2 + elif isinstance(self.client.framer, ModbusAsciiFramer): + min_size = 5 + elif isinstance(self.client.framer, ModbusBinaryFramer): + min_size = 3 + else: + min_size = expected_response_length + + read_min = self.client.framer.recvPacket(min_size) + if len(read_min) != min_size: + msg_start = "Incomplete message" if read_min else "No response" + raise InvalidMessageReceivedException( + "%s received, expected at least %d bytes " + "(%d received)" % (msg_start, min_size, len(read_min)) + ) + if read_min: + if isinstance(self.client.framer, ModbusSocketFramer): + func_code = byte2int(read_min[-1]) + elif isinstance(self.client.framer, ModbusRtuFramer): + func_code = byte2int(read_min[-1]) + elif isinstance(self.client.framer, ModbusAsciiFramer): + func_code = int(read_min[3:5], 16) + elif isinstance(self.client.framer, ModbusBinaryFramer): + func_code = byte2int(read_min[-1]) + else: + func_code = -1 + + if func_code < 0x80: # Not an error + if isinstance(self.client.framer, ModbusSocketFramer): + # Ommit UID, which is included in header size + h_size = self.client.framer._hsize + length = struct.unpack(">H", read_min[4:6])[0] - 1 + expected_response_length = h_size + length + if expected_response_length is not None: + expected_response_length -= min_size + total = expected_response_length + min_size + else: + expected_response_length = exception_length - min_size + total = expected_response_length + min_size + else: + total = expected_response_length + else: + read_min = b'' + total = expected_response_length + result = self.client.framer.recvPacket(expected_response_length) + result = read_min + result + actual = len(result) + if total is not None and actual != total: + msg_start = "Incomplete message" if actual else "No response" + _logger.debug("{} received, " + "Expected {} bytes Recieved " + "{} bytes !!!!".format(msg_start, total, actual)) + elif actual == 0: + # If actual == 0 and total is not None then the above + # should be triggered, so total must be None here + _logger.debug("No response received to unbounded read !!!!") + if self.client.state != ModbusTransactionState.PROCESSING_REPLY: + _logger.debug("Changing transaction state from " + "'WAITING FOR REPLY' to 'PROCESSING REPLY'") + self.client.state = ModbusTransactionState.PROCESSING_REPLY + return result + + def addTransaction(self, request, tid=None): + """ Adds a transaction to the handler + + This holds the request in case it needs to be resent. + After being sent, the request is removed. + + :param request: The request to hold on to + :param tid: The overloaded transaction id to use + """ + raise NotImplementedException("addTransaction") + + def getTransaction(self, tid): + """ Returns a transaction matching the referenced tid + + If the transaction does not exist, None is returned + + :param tid: The transaction to retrieve + """ + raise NotImplementedException("getTransaction") + + def delTransaction(self, tid): + """ Removes a transaction matching the referenced tid + + :param tid: The transaction to remove + """ + raise NotImplementedException("delTransaction") + + def getNextTID(self): + """ Retrieve the next unique transaction identifier + + This handles incrementing the identifier after + retrieval + + :returns: The next unique transaction identifier + """ + self.tid = (self.tid + 1) & 0xffff + return self.tid + + def reset(self): + """ Resets the transaction identifier """ + self.tid = Defaults.TransactionId + self.transactions = type(self.transactions)() + + +class DictTransactionManager(ModbusTransactionManager): + """ Impelements a transaction for a manager where the + results are keyed based on the supplied transaction id. + """ + + def __init__(self, client, **kwargs): + """ Initializes an instance of the ModbusTransactionManager + + :param client: The client socket wrapper + """ + self.transactions = {} + super(DictTransactionManager, self).__init__(client, **kwargs) + + def __iter__(self): + """ Iterater over the current managed transactions + + :returns: An iterator of the managed transactions + """ + return iterkeys(self.transactions) + + def addTransaction(self, request, tid=None): + """ Adds a transaction to the handler + + This holds the requets in case it needs to be resent. + After being sent, the request is removed. + + :param request: The request to hold on to + :param tid: The overloaded transaction id to use + """ + tid = tid if tid != None else request.transaction_id + _logger.debug("Adding transaction %d" % tid) + self.transactions[tid] = request + + def getTransaction(self, tid): + """ Returns a transaction matching the referenced tid + + If the transaction does not exist, None is returned + + :param tid: The transaction to retrieve + + """ + _logger.debug("Getting transaction %d" % tid) + + return self.transactions.pop(tid, None) + + def delTransaction(self, tid): + """ Removes a transaction matching the referenced tid + + :param tid: The transaction to remove + """ + _logger.debug("deleting transaction %d" % tid) + + self.transactions.pop(tid, None) + + +class FifoTransactionManager(ModbusTransactionManager): + """ Impelements a transaction for a manager where the + results are returned in a FIFO manner. + """ + + def __init__(self, client, **kwargs): + """ Initializes an instance of the ModbusTransactionManager + + :param client: The client socket wrapper + """ + super(FifoTransactionManager, self).__init__(client, **kwargs) + self.transactions = [] + + def __iter__(self): + """ Iterater over the current managed transactions + + :returns: An iterator of the managed transactions + """ + return iter(self.transactions) + + def addTransaction(self, request, tid=None): + """ Adds a transaction to the handler + + This holds the requets in case it needs to be resent. + After being sent, the request is removed. + + :param request: The request to hold on to + :param tid: The overloaded transaction id to use + """ + tid = tid if tid is not None else request.transaction_id + _logger.debug("Adding transaction %d" % tid) + + self.transactions.append(request) + + def getTransaction(self, tid): + """ Returns a transaction matching the referenced tid + + If the transaction does not exist, None is returned + + :param tid: The transaction to retrieve + """ + return self.transactions.pop(0) if self.transactions else None + + def delTransaction(self, tid): + """ Removes a transaction matching the referenced tid + + :param tid: The transaction to remove + """ + _logger.debug("Deleting transaction %d" % tid) + if self.transactions: self.transactions.pop(0) + +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # + +__all__ = [ + "FifoTransactionManager", + "DictTransactionManager", + "ModbusSocketFramer", "ModbusTlsFramer", "ModbusRtuFramer", + "ModbusAsciiFramer", "ModbusBinaryFramer", +] diff --git a/modbus/pymodbus/utilities.py b/modbus/pymodbus/utilities.py new file mode 100644 index 00000000..3f7e7578 --- /dev/null +++ b/modbus/pymodbus/utilities.py @@ -0,0 +1,258 @@ +""" +Modbus Utilities +----------------- + +A collection of utilities for packing data, unpacking +data computing checksums, and decode checksums. +""" +from pymodbus.compat import int2byte, byte2int, IS_PYTHON3 +from six import string_types + + +class ModbusTransactionState(object): + """ + Modbus Client States + """ + IDLE = 0 + SENDING = 1 + WAITING_FOR_REPLY = 2 + WAITING_TURNAROUND_DELAY = 3 + PROCESSING_REPLY = 4 + PROCESSING_ERROR = 5 + TRANSACTION_COMPLETE = 6 + RETRYING = 7 + NO_RESPONSE_STATE = 8 + + @classmethod + def to_string(cls, state): + states = { + ModbusTransactionState.IDLE: "IDLE", + ModbusTransactionState.SENDING: "SENDING", + ModbusTransactionState.WAITING_FOR_REPLY: "WAITING_FOR_REPLY", + ModbusTransactionState.WAITING_TURNAROUND_DELAY: "WAITING_TURNAROUND_DELAY", + ModbusTransactionState.PROCESSING_REPLY: "PROCESSING_REPLY", + ModbusTransactionState.PROCESSING_ERROR: "PROCESSING_ERROR", + ModbusTransactionState.TRANSACTION_COMPLETE: "TRANSACTION_COMPLETE", + ModbusTransactionState.RETRYING: "RETRYING TRANSACTION", + } + return states.get(state, None) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + +def default(value): + """ + Given a python object, return the default value + of that object. + + :param value: The value to get the default of + :returns: The default value + """ + return type(value)() + + +def dict_property(store, index): + """ Helper to create class properties from a dictionary. + Basically this allows you to remove a lot of possible + boilerplate code. + + :param store: The store store to pull from + :param index: The index into the store to close over + :returns: An initialized property set + """ + if hasattr(store, '__call__'): + getter = lambda self: store(self)[index] + setter = lambda self, value: store(self).__setitem__(index, value) + elif isinstance(store, str): + getter = lambda self: self.__getattribute__(store)[index] + setter = lambda self, value: self.__getattribute__(store).__setitem__( + index, value) + else: + getter = lambda self: store[index] + setter = lambda self, value: store.__setitem__(index, value) + + return property(getter, setter) + + +# --------------------------------------------------------------------------- # +# Bit packing functions +# --------------------------------------------------------------------------- # +def pack_bitstring(bits): + """ Creates a string out of an array of bits + + :param bits: A bit array + + example:: + + bits = [False, True, False, True] + result = pack_bitstring(bits) + """ + ret = b'' + i = packed = 0 + for bit in bits: + if bit: + packed += 128 + i += 1 + if i == 8: + ret += int2byte(packed) + i = packed = 0 + else: + packed >>= 1 + if 0 < i < 8: + packed >>= (7 - i) + ret += int2byte(packed) + return ret + + +def unpack_bitstring(string): + """ Creates bit array out of a string + + :param string: The modbus data packet to decode + + example:: + + bytes = 'bytes to decode' + result = unpack_bitstring(bytes) + """ + byte_count = len(string) + bits = [] + for byte in range(byte_count): + if IS_PYTHON3: + value = byte2int(int(string[byte])) + else: + value = byte2int(string[byte]) + for _ in range(8): + bits.append((value & 1) == 1) + value >>= 1 + return bits + + +def make_byte_string(s): + """ + Returns byte string from a given string, python3 specific fix + :param s: + :return: + """ + if IS_PYTHON3 and isinstance(s, string_types): + s = s.encode() + return s +# --------------------------------------------------------------------------- # +# Error Detection Functions +# --------------------------------------------------------------------------- # +def __generate_crc16_table(): + """ Generates a crc16 lookup table + + .. note:: This will only be generated once + """ + result = [] + for byte in range(256): + crc = 0x0000 + for _ in range(8): + if (byte ^ crc) & 0x0001: + crc = (crc >> 1) ^ 0xa001 + else: crc >>= 1 + byte >>= 1 + result.append(crc) + return result + +__crc16_table = __generate_crc16_table() + + +def computeCRC(data): + """ Computes a crc16 on the passed in string. For modbus, + this is only used on the binary serial protocols (in this + case RTU). + + The difference between modbus's crc16 and a normal crc16 + is that modbus starts the crc value out at 0xffff. + + :param data: The data to create a crc16 of + :returns: The calculated CRC + """ + crc = 0xffff + for a in data: + idx = __crc16_table[(crc ^ byte2int(a)) & 0xff] + crc = ((crc >> 8) & 0xff) ^ idx + swapped = ((crc << 8) & 0xff00) | ((crc >> 8) & 0x00ff) + return swapped + + +def checkCRC(data, check): + """ Checks if the data matches the passed in CRC + + :param data: The data to create a crc16 of + :param check: The CRC to validate + :returns: True if matched, False otherwise + """ + return computeCRC(data) == check + + +def computeLRC(data): + """ Used to compute the longitudinal redundancy check + against a string. This is only used on the serial ASCII + modbus protocol. A full description of this implementation + can be found in appendex B of the serial line modbus description. + + :param data: The data to apply a lrc to + :returns: The calculated LRC + + """ + lrc = sum(byte2int(a) for a in data) & 0xff + lrc = (lrc ^ 0xff) + 1 + return lrc & 0xff + + +def checkLRC(data, check): + """ Checks if the passed in data matches the LRC + + :param data: The data to calculate + :param check: The LRC to validate + :returns: True if matched, False otherwise + """ + return computeLRC(data) == check + + +def rtuFrameSize(data, byte_count_pos): + """ Calculates the size of the frame based on the byte count. + + :param data: The buffer containing the frame. + :param byte_count_pos: The index of the byte count in the buffer. + :returns: The size of the frame. + + The structure of frames with a byte count field is always the + same: + + - first, there are some header fields + - then the byte count field + - then as many data bytes as indicated by the byte count, + - finally the CRC (two bytes). + + To calculate the frame size, it is therefore sufficient to extract + the contents of the byte count field, add the position of this + field, and finally increment the sum by three (one byte for the + byte count field, two for the CRC). + """ + return byte2int(data[byte_count_pos]) + byte_count_pos + 3 + + +def hexlify_packets(packet): + """ + Returns hex representation of bytestring received + :param packet: + :return: + """ + if not packet: + return '' + if IS_PYTHON3: + return " ".join([hex(byte2int(x)) for x in packet]) + else: + return u" ".join([hex(byte2int(x)) for x in packet]) +# --------------------------------------------------------------------------- # +# Exported symbols +# --------------------------------------------------------------------------- # +__all__ = [ + 'pack_bitstring', 'unpack_bitstring', 'default', + 'computeCRC', 'checkCRC', 'computeLRC', 'checkLRC', 'rtuFrameSize' +] diff --git a/modbus/pymodbus/version.py b/modbus/pymodbus/version.py new file mode 100644 index 00000000..12e7eaaa --- /dev/null +++ b/modbus/pymodbus/version.py @@ -0,0 +1,52 @@ +""" +Handle the version information here; you should only have to +change the version tuple. + +Since we are using twisted's version class, we can also query +the svn version as well using the local .entries file. +""" + + +class Version(object): + + def __init__(self, package, major, minor, micro, pre=None): + """ + + :param package: Name of the package that this is a version of. + :param major: The major version number. + :param minor: The minor version number. + :param micro: The micro version number. + :param pre: The pre release tag + """ + self.package = package + self.major = major + self.minor = minor + self.micro = micro + self.pre = pre + + def short(self): + """ Return a string in canonical short version format + ...
+        """
+        if self.pre:
+            return '%d.%d.%d.%s' % (self.major, self.minor, self.micro, self.pre)
+        else:
+            return '%d.%d.%d' % (self.major, self.minor, self.micro)
+
+    def __str__(self):
+        """ Returns a string representation of the object
+
+        :returns: A string representation of this object
+        """
+        return '[%s, version %s]' % (self.package, self.short())
+
+
+version = Version('pymodbus', 2, 5, 3)
+
+version.__name__ = 'pymodbus'  # fix epydoc error
+
+# --------------------------------------------------------------------------- #
+# Exported symbols
+# --------------------------------------------------------------------------- #
+
+__all__ = ["version"]
diff --git a/modbus/serial/__init__.py b/modbus/serial/__init__.py
new file mode 100644
index 00000000..caa4de1f
--- /dev/null
+++ b/modbus/serial/__init__.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python
+#
+# This is a wrapper module for different platform implementations
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2001-2020 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+import sys
+import importlib
+
+from serial.serialutil import *
+#~ SerialBase, SerialException, to_bytes, iterbytes
+
+__version__ = '3.5'
+
+VERSION = __version__
+
+# pylint: disable=wrong-import-position
+if sys.platform == 'cli':
+    from serial.serialcli import Serial
+else:
+    import os
+    # chose an implementation, depending on os
+    if os.name == 'nt':  # sys.platform == 'win32':
+        from serial.serialwin32 import Serial
+    elif os.name == 'posix':
+        from serial.serialposix import Serial, PosixPollSerial, VTIMESerial  # noqa
+    elif os.name == 'java':
+        from serial.serialjava import Serial
+    else:
+        raise ImportError("Sorry: no implementation for your platform ('{}') available".format(os.name))
+
+
+protocol_handler_packages = [
+    'serial.urlhandler',
+]
+
+
+def serial_for_url(url, *args, **kwargs):
+    """\
+    Get an instance of the Serial class, depending on port/url. The port is not
+    opened when the keyword parameter 'do_not_open' is true, by default it
+    is. All other parameters are directly passed to the __init__ method when
+    the port is instantiated.
+
+    The list of package names that is searched for protocol handlers is kept in
+    ``protocol_handler_packages``.
+
+    e.g. we want to support a URL ``foobar://``. A module
+    ``my_handlers.protocol_foobar`` is provided by the user. Then
+    ``protocol_handler_packages.append("my_handlers")`` would extend the search
+    path so that ``serial_for_url("foobar://"))`` would work.
+    """
+    # check and remove extra parameter to not confuse the Serial class
+    do_open = not kwargs.pop('do_not_open', False)
+    # the default is to use the native implementation
+    klass = Serial
+    try:
+        url_lowercase = url.lower()
+    except AttributeError:
+        # it's not a string, use default
+        pass
+    else:
+        # if it is an URL, try to import the handler module from the list of possible packages
+        if '://' in url_lowercase:
+            protocol = url_lowercase.split('://', 1)[0]
+            module_name = '.protocol_{}'.format(protocol)
+            for package_name in protocol_handler_packages:
+                try:
+                    importlib.import_module(package_name)
+                    handler_module = importlib.import_module(module_name, package_name)
+                except ImportError:
+                    continue
+                else:
+                    if hasattr(handler_module, 'serial_class_for_url'):
+                        url, klass = handler_module.serial_class_for_url(url)
+                    else:
+                        klass = handler_module.Serial
+                    break
+            else:
+                raise ValueError('invalid URL, protocol {!r} not known'.format(protocol))
+    # instantiate and open when desired
+    instance = klass(None, *args, **kwargs)
+    instance.port = url
+    if do_open:
+        instance.open()
+    return instance
diff --git a/modbus/serial/__main__.py b/modbus/serial/__main__.py
new file mode 100644
index 00000000..bd0a2e63
--- /dev/null
+++ b/modbus/serial/__main__.py
@@ -0,0 +1,3 @@
+from .tools import miniterm
+
+miniterm.main()
diff --git a/modbus/serial/rfc2217.py b/modbus/serial/rfc2217.py
new file mode 100644
index 00000000..2ae188ed
--- /dev/null
+++ b/modbus/serial/rfc2217.py
@@ -0,0 +1,1351 @@
+#! python
+#
+# This module implements a RFC2217 compatible client. RF2217 descibes a
+# protocol to access serial ports over TCP/IP and allows setting the baud rate,
+# modem control lines etc.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2001-2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+# TODO:
+# - setting control line -> answer is not checked (had problems with one of the
+#   severs). consider implementing a compatibility mode flag to make check
+#   conditional
+# - write timeout not implemented at all
+
+# ###########################################################################
+# observations and issues with servers
+# ===========================================================================
+# sredird V2.2.1
+# - http://www.ibiblio.org/pub/Linux/system/serial/   sredird-2.2.2.tar.gz
+# - does not acknowledge SET_CONTROL (RTS/DTR) correctly, always responding
+#   [105 1] instead of the actual value.
+# - SET_BAUDRATE answer contains 4 extra null bytes -> probably for larger
+#   numbers than 2**32?
+# - To get the signature [COM_PORT_OPTION 0] has to be sent.
+# - run a server: while true; do nc -l -p 7000 -c "sredird debug /dev/ttyUSB0 /var/lock/sredir"; done
+# ===========================================================================
+# telnetcpcd (untested)
+# - http://ftp.wayne.edu/kermit/sredird/telnetcpcd-1.09.tar.gz
+# - To get the signature [COM_PORT_OPTION] w/o data has to be sent.
+# ===========================================================================
+# ser2net
+# - does not negotiate BINARY or COM_PORT_OPTION for his side but at least
+#   acknowledges that the client activates these options
+# - The configuration may be that the server prints a banner. As this client
+#   implementation does a flushInput on connect, this banner is hidden from
+#   the user application.
+# - NOTIFY_MODEMSTATE: the poll interval of the server seems to be one
+#   second.
+# - To get the signature [COM_PORT_OPTION 0] has to be sent.
+# - run a server: run ser2net daemon, in /etc/ser2net.conf:
+#     2000:telnet:0:/dev/ttyS0:9600 remctl banner
+# ###########################################################################
+
+# How to identify ports? pySerial might want to support other protocols in the
+# future, so lets use an URL scheme.
+# for RFC2217 compliant servers we will use this:
+#    rfc2217://:[?option[&option...]]
+#
+# options:
+# - "logging" set log level print diagnostic messages (e.g. "logging=debug")
+# - "ign_set_control": do not look at the answers to SET_CONTROL
+# - "poll_modem": issue NOTIFY_MODEMSTATE requests when CTS/DTR/RI/CD is read.
+#   Without this option it expects that the server sends notifications
+#   automatically on change (which most servers do and is according to the
+#   RFC).
+# the order of the options is not relevant
+
+from __future__ import absolute_import
+
+import logging
+import socket
+import struct
+import threading
+import time
+try:
+    import urlparse
+except ImportError:
+    import urllib.parse as urlparse
+try:
+    import Queue
+except ImportError:
+    import queue as Queue
+
+import serial
+from serial.serialutil import SerialBase, SerialException, to_bytes, \
+    iterbytes, PortNotOpenError, Timeout
+
+# port string is expected to be something like this:
+# rfc2217://host:port
+# host may be an IP or including domain, whatever.
+# port is 0...65535
+
+# map log level names to constants. used in from_url()
+LOGGER_LEVELS = {
+    'debug': logging.DEBUG,
+    'info': logging.INFO,
+    'warning': logging.WARNING,
+    'error': logging.ERROR,
+}
+
+
+# telnet protocol characters
+SE = b'\xf0'    # Subnegotiation End
+NOP = b'\xf1'   # No Operation
+DM = b'\xf2'    # Data Mark
+BRK = b'\xf3'   # Break
+IP = b'\xf4'    # Interrupt process
+AO = b'\xf5'    # Abort output
+AYT = b'\xf6'   # Are You There
+EC = b'\xf7'    # Erase Character
+EL = b'\xf8'    # Erase Line
+GA = b'\xf9'    # Go Ahead
+SB = b'\xfa'    # Subnegotiation Begin
+WILL = b'\xfb'
+WONT = b'\xfc'
+DO = b'\xfd'
+DONT = b'\xfe'
+IAC = b'\xff'   # Interpret As Command
+IAC_DOUBLED = b'\xff\xff'
+
+# selected telnet options
+BINARY = b'\x00'    # 8-bit data path
+ECHO = b'\x01'      # echo
+SGA = b'\x03'       # suppress go ahead
+
+# RFC2217
+COM_PORT_OPTION = b'\x2c'
+
+# Client to Access Server
+SET_BAUDRATE = b'\x01'
+SET_DATASIZE = b'\x02'
+SET_PARITY = b'\x03'
+SET_STOPSIZE = b'\x04'
+SET_CONTROL = b'\x05'
+NOTIFY_LINESTATE = b'\x06'
+NOTIFY_MODEMSTATE = b'\x07'
+FLOWCONTROL_SUSPEND = b'\x08'
+FLOWCONTROL_RESUME = b'\x09'
+SET_LINESTATE_MASK = b'\x0a'
+SET_MODEMSTATE_MASK = b'\x0b'
+PURGE_DATA = b'\x0c'
+
+SERVER_SET_BAUDRATE = b'\x65'
+SERVER_SET_DATASIZE = b'\x66'
+SERVER_SET_PARITY = b'\x67'
+SERVER_SET_STOPSIZE = b'\x68'
+SERVER_SET_CONTROL = b'\x69'
+SERVER_NOTIFY_LINESTATE = b'\x6a'
+SERVER_NOTIFY_MODEMSTATE = b'\x6b'
+SERVER_FLOWCONTROL_SUSPEND = b'\x6c'
+SERVER_FLOWCONTROL_RESUME = b'\x6d'
+SERVER_SET_LINESTATE_MASK = b'\x6e'
+SERVER_SET_MODEMSTATE_MASK = b'\x6f'
+SERVER_PURGE_DATA = b'\x70'
+
+RFC2217_ANSWER_MAP = {
+    SET_BAUDRATE: SERVER_SET_BAUDRATE,
+    SET_DATASIZE: SERVER_SET_DATASIZE,
+    SET_PARITY: SERVER_SET_PARITY,
+    SET_STOPSIZE: SERVER_SET_STOPSIZE,
+    SET_CONTROL: SERVER_SET_CONTROL,
+    NOTIFY_LINESTATE: SERVER_NOTIFY_LINESTATE,
+    NOTIFY_MODEMSTATE: SERVER_NOTIFY_MODEMSTATE,
+    FLOWCONTROL_SUSPEND: SERVER_FLOWCONTROL_SUSPEND,
+    FLOWCONTROL_RESUME: SERVER_FLOWCONTROL_RESUME,
+    SET_LINESTATE_MASK: SERVER_SET_LINESTATE_MASK,
+    SET_MODEMSTATE_MASK: SERVER_SET_MODEMSTATE_MASK,
+    PURGE_DATA: SERVER_PURGE_DATA,
+}
+
+SET_CONTROL_REQ_FLOW_SETTING = b'\x00'        # Request Com Port Flow Control Setting (outbound/both)
+SET_CONTROL_USE_NO_FLOW_CONTROL = b'\x01'     # Use No Flow Control (outbound/both)
+SET_CONTROL_USE_SW_FLOW_CONTROL = b'\x02'     # Use XON/XOFF Flow Control (outbound/both)
+SET_CONTROL_USE_HW_FLOW_CONTROL = b'\x03'     # Use HARDWARE Flow Control (outbound/both)
+SET_CONTROL_REQ_BREAK_STATE = b'\x04'         # Request BREAK State
+SET_CONTROL_BREAK_ON = b'\x05'                # Set BREAK State ON
+SET_CONTROL_BREAK_OFF = b'\x06'               # Set BREAK State OFF
+SET_CONTROL_REQ_DTR = b'\x07'                 # Request DTR Signal State
+SET_CONTROL_DTR_ON = b'\x08'                  # Set DTR Signal State ON
+SET_CONTROL_DTR_OFF = b'\x09'                 # Set DTR Signal State OFF
+SET_CONTROL_REQ_RTS = b'\x0a'                 # Request RTS Signal State
+SET_CONTROL_RTS_ON = b'\x0b'                  # Set RTS Signal State ON
+SET_CONTROL_RTS_OFF = b'\x0c'                 # Set RTS Signal State OFF
+SET_CONTROL_REQ_FLOW_SETTING_IN = b'\x0d'     # Request Com Port Flow Control Setting (inbound)
+SET_CONTROL_USE_NO_FLOW_CONTROL_IN = b'\x0e'  # Use No Flow Control (inbound)
+SET_CONTROL_USE_SW_FLOW_CONTOL_IN = b'\x0f'   # Use XON/XOFF Flow Control (inbound)
+SET_CONTROL_USE_HW_FLOW_CONTOL_IN = b'\x10'   # Use HARDWARE Flow Control (inbound)
+SET_CONTROL_USE_DCD_FLOW_CONTROL = b'\x11'    # Use DCD Flow Control (outbound/both)
+SET_CONTROL_USE_DTR_FLOW_CONTROL = b'\x12'    # Use DTR Flow Control (inbound)
+SET_CONTROL_USE_DSR_FLOW_CONTROL = b'\x13'    # Use DSR Flow Control (outbound/both)
+
+LINESTATE_MASK_TIMEOUT = 128        # Time-out Error
+LINESTATE_MASK_SHIFTREG_EMPTY = 64  # Transfer Shift Register Empty
+LINESTATE_MASK_TRANSREG_EMPTY = 32  # Transfer Holding Register Empty
+LINESTATE_MASK_BREAK_DETECT = 16    # Break-detect Error
+LINESTATE_MASK_FRAMING_ERROR = 8    # Framing Error
+LINESTATE_MASK_PARTIY_ERROR = 4     # Parity Error
+LINESTATE_MASK_OVERRUN_ERROR = 2    # Overrun Error
+LINESTATE_MASK_DATA_READY = 1       # Data Ready
+
+MODEMSTATE_MASK_CD = 128            # Receive Line Signal Detect (also known as Carrier Detect)
+MODEMSTATE_MASK_RI = 64             # Ring Indicator
+MODEMSTATE_MASK_DSR = 32            # Data-Set-Ready Signal State
+MODEMSTATE_MASK_CTS = 16            # Clear-To-Send Signal State
+MODEMSTATE_MASK_CD_CHANGE = 8       # Delta Receive Line Signal Detect
+MODEMSTATE_MASK_RI_CHANGE = 4       # Trailing-edge Ring Detector
+MODEMSTATE_MASK_DSR_CHANGE = 2      # Delta Data-Set-Ready
+MODEMSTATE_MASK_CTS_CHANGE = 1      # Delta Clear-To-Send
+
+PURGE_RECEIVE_BUFFER = b'\x01'      # Purge access server receive data buffer
+PURGE_TRANSMIT_BUFFER = b'\x02'     # Purge access server transmit data buffer
+PURGE_BOTH_BUFFERS = b'\x03'        # Purge both the access server receive data
+                                    # buffer and the access server transmit data buffer
+
+
+RFC2217_PARITY_MAP = {
+    serial.PARITY_NONE: 1,
+    serial.PARITY_ODD: 2,
+    serial.PARITY_EVEN: 3,
+    serial.PARITY_MARK: 4,
+    serial.PARITY_SPACE: 5,
+}
+RFC2217_REVERSE_PARITY_MAP = dict((v, k) for k, v in RFC2217_PARITY_MAP.items())
+
+RFC2217_STOPBIT_MAP = {
+    serial.STOPBITS_ONE: 1,
+    serial.STOPBITS_ONE_POINT_FIVE: 3,
+    serial.STOPBITS_TWO: 2,
+}
+RFC2217_REVERSE_STOPBIT_MAP = dict((v, k) for k, v in RFC2217_STOPBIT_MAP.items())
+
+# Telnet filter states
+M_NORMAL = 0
+M_IAC_SEEN = 1
+M_NEGOTIATE = 2
+
+# TelnetOption and TelnetSubnegotiation states
+REQUESTED = 'REQUESTED'
+ACTIVE = 'ACTIVE'
+INACTIVE = 'INACTIVE'
+REALLY_INACTIVE = 'REALLY_INACTIVE'
+
+
+class TelnetOption(object):
+    """Manage a single telnet option, keeps track of DO/DONT WILL/WONT."""
+
+    def __init__(self, connection, name, option, send_yes, send_no, ack_yes,
+                 ack_no, initial_state, activation_callback=None):
+        """\
+        Initialize option.
+        :param connection: connection used to transmit answers
+        :param name: a readable name for debug outputs
+        :param send_yes: what to send when option is to be enabled.
+        :param send_no: what to send when option is to be disabled.
+        :param ack_yes: what to expect when remote agrees on option.
+        :param ack_no: what to expect when remote disagrees on option.
+        :param initial_state: options initialized with REQUESTED are tried to
+            be enabled on startup. use INACTIVE for all others.
+        """
+        self.connection = connection
+        self.name = name
+        self.option = option
+        self.send_yes = send_yes
+        self.send_no = send_no
+        self.ack_yes = ack_yes
+        self.ack_no = ack_no
+        self.state = initial_state
+        self.active = False
+        self.activation_callback = activation_callback
+
+    def __repr__(self):
+        """String for debug outputs"""
+        return "{o.name}:{o.active}({o.state})".format(o=self)
+
+    def process_incoming(self, command):
+        """\
+        A DO/DONT/WILL/WONT was received for this option, update state and
+        answer when needed.
+        """
+        if command == self.ack_yes:
+            if self.state is REQUESTED:
+                self.state = ACTIVE
+                self.active = True
+                if self.activation_callback is not None:
+                    self.activation_callback()
+            elif self.state is ACTIVE:
+                pass
+            elif self.state is INACTIVE:
+                self.state = ACTIVE
+                self.connection.telnet_send_option(self.send_yes, self.option)
+                self.active = True
+                if self.activation_callback is not None:
+                    self.activation_callback()
+            elif self.state is REALLY_INACTIVE:
+                self.connection.telnet_send_option(self.send_no, self.option)
+            else:
+                raise ValueError('option in illegal state {!r}'.format(self))
+        elif command == self.ack_no:
+            if self.state is REQUESTED:
+                self.state = INACTIVE
+                self.active = False
+            elif self.state is ACTIVE:
+                self.state = INACTIVE
+                self.connection.telnet_send_option(self.send_no, self.option)
+                self.active = False
+            elif self.state is INACTIVE:
+                pass
+            elif self.state is REALLY_INACTIVE:
+                pass
+            else:
+                raise ValueError('option in illegal state {!r}'.format(self))
+
+
+class TelnetSubnegotiation(object):
+    """\
+    A object to handle subnegotiation of options. In this case actually
+    sub-sub options for RFC 2217. It is used to track com port options.
+    """
+
+    def __init__(self, connection, name, option, ack_option=None):
+        if ack_option is None:
+            ack_option = option
+        self.connection = connection
+        self.name = name
+        self.option = option
+        self.value = None
+        self.ack_option = ack_option
+        self.state = INACTIVE
+
+    def __repr__(self):
+        """String for debug outputs."""
+        return "{sn.name}:{sn.state}".format(sn=self)
+
+    def set(self, value):
+        """\
+        Request a change of the value. a request is sent to the server. if
+        the client needs to know if the change is performed he has to check the
+        state of this object.
+        """
+        self.value = value
+        self.state = REQUESTED
+        self.connection.rfc2217_send_subnegotiation(self.option, self.value)
+        if self.connection.logger:
+            self.connection.logger.debug("SB Requesting {} -> {!r}".format(self.name, self.value))
+
+    def is_ready(self):
+        """\
+        Check if answer from server has been received. when server rejects
+        the change, raise a ValueError.
+        """
+        if self.state == REALLY_INACTIVE:
+            raise ValueError("remote rejected value for option {!r}".format(self.name))
+        return self.state == ACTIVE
+    # add property to have a similar interface as TelnetOption
+    active = property(is_ready)
+
+    def wait(self, timeout=3):
+        """\
+        Wait until the subnegotiation has been acknowledged or timeout. It
+        can also throw a value error when the answer from the server does not
+        match the value sent.
+        """
+        timeout_timer = Timeout(timeout)
+        while not timeout_timer.expired():
+            time.sleep(0.05)    # prevent 100% CPU load
+            if self.is_ready():
+                break
+        else:
+            raise SerialException("timeout while waiting for option {!r}".format(self.name))
+
+    def check_answer(self, suboption):
+        """\
+        Check an incoming subnegotiation block. The parameter already has
+        cut off the header like sub option number and com port option value.
+        """
+        if self.value == suboption[:len(self.value)]:
+            self.state = ACTIVE
+        else:
+            # error propagation done in is_ready
+            self.state = REALLY_INACTIVE
+        if self.connection.logger:
+            self.connection.logger.debug("SB Answer {} -> {!r} -> {}".format(self.name, suboption, self.state))
+
+
+class Serial(SerialBase):
+    """Serial port implementation for RFC 2217 remote serial ports."""
+
+    BAUDRATES = (50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800,
+                 9600, 19200, 38400, 57600, 115200)
+
+    def __init__(self, *args, **kwargs):
+        self._thread = None
+        self._socket = None
+        self._linestate = 0
+        self._modemstate = None
+        self._modemstate_timeout = Timeout(-1)
+        self._remote_suspend_flow = False
+        self._write_lock = None
+        self.logger = None
+        self._ignore_set_control_answer = False
+        self._poll_modem_state = False
+        self._network_timeout = 3
+        self._telnet_options = None
+        self._rfc2217_port_settings = None
+        self._rfc2217_options = None
+        self._read_buffer = None
+        super(Serial, self).__init__(*args, **kwargs)  # must be last call in case of auto-open
+
+    def open(self):
+        """\
+        Open port with current settings. This may throw a SerialException
+        if the port cannot be opened.
+        """
+        self.logger = None
+        self._ignore_set_control_answer = False
+        self._poll_modem_state = False
+        self._network_timeout = 3
+        if self._port is None:
+            raise SerialException("Port must be configured before it can be used.")
+        if self.is_open:
+            raise SerialException("Port is already open.")
+        try:
+            self._socket = socket.create_connection(self.from_url(self.portstr), timeout=5)  # XXX good value?
+            self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+        except Exception as msg:
+            self._socket = None
+            raise SerialException("Could not open port {}: {}".format(self.portstr, msg))
+
+        # use a thread save queue as buffer. it also simplifies implementing
+        # the read timeout
+        self._read_buffer = Queue.Queue()
+        # to ensure that user writes does not interfere with internal
+        # telnet/rfc2217 options establish a lock
+        self._write_lock = threading.Lock()
+        # name the following separately so that, below, a check can be easily done
+        mandadory_options = [
+            TelnetOption(self, 'we-BINARY', BINARY, WILL, WONT, DO, DONT, INACTIVE),
+            TelnetOption(self, 'we-RFC2217', COM_PORT_OPTION, WILL, WONT, DO, DONT, REQUESTED),
+        ]
+        # all supported telnet options
+        self._telnet_options = [
+            TelnetOption(self, 'ECHO', ECHO, DO, DONT, WILL, WONT, REQUESTED),
+            TelnetOption(self, 'we-SGA', SGA, WILL, WONT, DO, DONT, REQUESTED),
+            TelnetOption(self, 'they-SGA', SGA, DO, DONT, WILL, WONT, REQUESTED),
+            TelnetOption(self, 'they-BINARY', BINARY, DO, DONT, WILL, WONT, INACTIVE),
+            TelnetOption(self, 'they-RFC2217', COM_PORT_OPTION, DO, DONT, WILL, WONT, REQUESTED),
+        ] + mandadory_options
+        # RFC 2217 specific states
+        # COM port settings
+        self._rfc2217_port_settings = {
+            'baudrate': TelnetSubnegotiation(self, 'baudrate', SET_BAUDRATE, SERVER_SET_BAUDRATE),
+            'datasize': TelnetSubnegotiation(self, 'datasize', SET_DATASIZE, SERVER_SET_DATASIZE),
+            'parity':   TelnetSubnegotiation(self, 'parity',   SET_PARITY,   SERVER_SET_PARITY),
+            'stopsize': TelnetSubnegotiation(self, 'stopsize', SET_STOPSIZE, SERVER_SET_STOPSIZE),
+        }
+        # There are more subnegotiation objects, combine all in one dictionary
+        # for easy access
+        self._rfc2217_options = {
+            'purge':    TelnetSubnegotiation(self, 'purge',    PURGE_DATA,   SERVER_PURGE_DATA),
+            'control':  TelnetSubnegotiation(self, 'control',  SET_CONTROL,  SERVER_SET_CONTROL),
+        }
+        self._rfc2217_options.update(self._rfc2217_port_settings)
+        # cache for line and modem states that the server sends to us
+        self._linestate = 0
+        self._modemstate = None
+        self._modemstate_timeout = Timeout(-1)
+        # RFC 2217 flow control between server and client
+        self._remote_suspend_flow = False
+
+        self.is_open = True
+        self._thread = threading.Thread(target=self._telnet_read_loop)
+        self._thread.setDaemon(True)
+        self._thread.setName('pySerial RFC 2217 reader thread for {}'.format(self._port))
+        self._thread.start()
+
+        try:    # must clean-up if open fails
+            # negotiate Telnet/RFC 2217 -> send initial requests
+            for option in self._telnet_options:
+                if option.state is REQUESTED:
+                    self.telnet_send_option(option.send_yes, option.option)
+            # now wait until important options are negotiated
+            timeout = Timeout(self._network_timeout)
+            while not timeout.expired():
+                time.sleep(0.05)    # prevent 100% CPU load
+                if sum(o.active for o in mandadory_options) == sum(o.state != INACTIVE for o in mandadory_options):
+                    break
+            else:
+                raise SerialException(
+                    "Remote does not seem to support RFC2217 or BINARY mode {!r}".format(mandadory_options))
+            if self.logger:
+                self.logger.info("Negotiated options: {}".format(self._telnet_options))
+
+            # fine, go on, set RFC 2217 specific things
+            self._reconfigure_port()
+            # all things set up get, now a clean start
+            if not self._dsrdtr:
+                self._update_dtr_state()
+            if not self._rtscts:
+                self._update_rts_state()
+            self.reset_input_buffer()
+            self.reset_output_buffer()
+        except:
+            self.close()
+            raise
+
+    def _reconfigure_port(self):
+        """Set communication parameters on opened port."""
+        if self._socket is None:
+            raise SerialException("Can only operate on open ports")
+
+        # if self._timeout != 0 and self._interCharTimeout is not None:
+            # XXX
+
+        if self._write_timeout is not None:
+            raise NotImplementedError('write_timeout is currently not supported')
+            # XXX
+
+        # Setup the connection
+        # to get good performance, all parameter changes are sent first...
+        if not 0 < self._baudrate < 2 ** 32:
+            raise ValueError("invalid baudrate: {!r}".format(self._baudrate))
+        self._rfc2217_port_settings['baudrate'].set(struct.pack(b'!I', self._baudrate))
+        self._rfc2217_port_settings['datasize'].set(struct.pack(b'!B', self._bytesize))
+        self._rfc2217_port_settings['parity'].set(struct.pack(b'!B', RFC2217_PARITY_MAP[self._parity]))
+        self._rfc2217_port_settings['stopsize'].set(struct.pack(b'!B', RFC2217_STOPBIT_MAP[self._stopbits]))
+
+        # and now wait until parameters are active
+        items = self._rfc2217_port_settings.values()
+        if self.logger:
+            self.logger.debug("Negotiating settings: {}".format(items))
+        timeout = Timeout(self._network_timeout)
+        while not timeout.expired():
+            time.sleep(0.05)    # prevent 100% CPU load
+            if sum(o.active for o in items) == len(items):
+                break
+        else:
+            raise SerialException("Remote does not accept parameter change (RFC2217): {!r}".format(items))
+        if self.logger:
+            self.logger.info("Negotiated settings: {}".format(items))
+
+        if self._rtscts and self._xonxoff:
+            raise ValueError('xonxoff and rtscts together are not supported')
+        elif self._rtscts:
+            self.rfc2217_set_control(SET_CONTROL_USE_HW_FLOW_CONTROL)
+        elif self._xonxoff:
+            self.rfc2217_set_control(SET_CONTROL_USE_SW_FLOW_CONTROL)
+        else:
+            self.rfc2217_set_control(SET_CONTROL_USE_NO_FLOW_CONTROL)
+
+    def close(self):
+        """Close port"""
+        self.is_open = False
+        if self._socket:
+            try:
+                self._socket.shutdown(socket.SHUT_RDWR)
+                self._socket.close()
+            except:
+                # ignore errors.
+                pass
+        if self._thread:
+            self._thread.join(7)  # XXX more than socket timeout
+            self._thread = None
+            # in case of quick reconnects, give the server some time
+            time.sleep(0.3)
+        self._socket = None
+
+    def from_url(self, url):
+        """\
+        extract host and port from an URL string, other settings are extracted
+        an stored in instance
+        """
+        parts = urlparse.urlsplit(url)
+        if parts.scheme != "rfc2217":
+            raise SerialException(
+                'expected a string in the form '
+                '"rfc2217://:[?option[&option...]]": '
+                'not starting with rfc2217:// ({!r})'.format(parts.scheme))
+        try:
+            # process options now, directly altering self
+            for option, values in urlparse.parse_qs(parts.query, True).items():
+                if option == 'logging':
+                    logging.basicConfig()   # XXX is that good to call it here?
+                    self.logger = logging.getLogger('pySerial.rfc2217')
+                    self.logger.setLevel(LOGGER_LEVELS[values[0]])
+                    self.logger.debug('enabled logging')
+                elif option == 'ign_set_control':
+                    self._ignore_set_control_answer = True
+                elif option == 'poll_modem':
+                    self._poll_modem_state = True
+                elif option == 'timeout':
+                    self._network_timeout = float(values[0])
+                else:
+                    raise ValueError('unknown option: {!r}'.format(option))
+            if not 0 <= parts.port < 65536:
+                raise ValueError("port not in range 0...65535")
+        except ValueError as e:
+            raise SerialException(
+                'expected a string in the form '
+                '"rfc2217://:[?option[&option...]]": {}'.format(e))
+        return (parts.hostname, parts.port)
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    @property
+    def in_waiting(self):
+        """Return the number of bytes currently in the input buffer."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return self._read_buffer.qsize()
+
+    def read(self, size=1):
+        """\
+        Read size bytes from the serial port. If a timeout is set it may
+        return less characters as requested. With no timeout it will block
+        until the requested number of bytes is read.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        data = bytearray()
+        try:
+            timeout = Timeout(self._timeout)
+            while len(data) < size:
+                if self._thread is None or not self._thread.is_alive():
+                    raise SerialException('connection failed (reader thread died)')
+                buf = self._read_buffer.get(True, timeout.time_left())
+                if buf is None:
+                    return bytes(data)
+                data += buf
+                if timeout.expired():
+                    break
+        except Queue.Empty:  # -> timeout
+            pass
+        return bytes(data)
+
+    def write(self, data):
+        """\
+        Output the given byte string over the serial port. Can block if the
+        connection is blocked. May raise SerialException if the connection is
+        closed.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        with self._write_lock:
+            try:
+                self._socket.sendall(to_bytes(data).replace(IAC, IAC_DOUBLED))
+            except socket.error as e:
+                raise SerialException("connection failed (socket error): {}".format(e))
+        return len(data)
+
+    def reset_input_buffer(self):
+        """Clear input buffer, discarding all that is in the buffer."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        self.rfc2217_send_purge(PURGE_RECEIVE_BUFFER)
+        # empty read buffer
+        while self._read_buffer.qsize():
+            self._read_buffer.get(False)
+
+    def reset_output_buffer(self):
+        """\
+        Clear output buffer, aborting the current output and
+        discarding all that is in the buffer.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        self.rfc2217_send_purge(PURGE_TRANSMIT_BUFFER)
+
+    def _update_break_state(self):
+        """\
+        Set break: Controls TXD. When active, to transmitting is
+        possible.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        if self.logger:
+            self.logger.info('set BREAK to {}'.format('active' if self._break_state else 'inactive'))
+        if self._break_state:
+            self.rfc2217_set_control(SET_CONTROL_BREAK_ON)
+        else:
+            self.rfc2217_set_control(SET_CONTROL_BREAK_OFF)
+
+    def _update_rts_state(self):
+        """Set terminal status line: Request To Send."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        if self.logger:
+            self.logger.info('set RTS to {}'.format('active' if self._rts_state else 'inactive'))
+        if self._rts_state:
+            self.rfc2217_set_control(SET_CONTROL_RTS_ON)
+        else:
+            self.rfc2217_set_control(SET_CONTROL_RTS_OFF)
+
+    def _update_dtr_state(self):
+        """Set terminal status line: Data Terminal Ready."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        if self.logger:
+            self.logger.info('set DTR to {}'.format('active' if self._dtr_state else 'inactive'))
+        if self._dtr_state:
+            self.rfc2217_set_control(SET_CONTROL_DTR_ON)
+        else:
+            self.rfc2217_set_control(SET_CONTROL_DTR_OFF)
+
+    @property
+    def cts(self):
+        """Read terminal status line: Clear To Send."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return bool(self.get_modem_state() & MODEMSTATE_MASK_CTS)
+
+    @property
+    def dsr(self):
+        """Read terminal status line: Data Set Ready."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return bool(self.get_modem_state() & MODEMSTATE_MASK_DSR)
+
+    @property
+    def ri(self):
+        """Read terminal status line: Ring Indicator."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return bool(self.get_modem_state() & MODEMSTATE_MASK_RI)
+
+    @property
+    def cd(self):
+        """Read terminal status line: Carrier Detect."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return bool(self.get_modem_state() & MODEMSTATE_MASK_CD)
+
+    # - - - platform specific - - -
+    # None so far
+
+    # - - - RFC2217 specific - - -
+
+    def _telnet_read_loop(self):
+        """Read loop for the socket."""
+        mode = M_NORMAL
+        suboption = None
+        try:
+            while self.is_open:
+                try:
+                    data = self._socket.recv(1024)
+                except socket.timeout:
+                    # just need to get out of recv form time to time to check if
+                    # still alive
+                    continue
+                except socket.error as e:
+                    # connection fails -> terminate loop
+                    if self.logger:
+                        self.logger.debug("socket error in reader thread: {}".format(e))
+                    self._read_buffer.put(None)
+                    break
+                if not data:
+                    self._read_buffer.put(None)
+                    break  # lost connection
+                for byte in iterbytes(data):
+                    if mode == M_NORMAL:
+                        # interpret as command or as data
+                        if byte == IAC:
+                            mode = M_IAC_SEEN
+                        else:
+                            # store data in read buffer or sub option buffer
+                            # depending on state
+                            if suboption is not None:
+                                suboption += byte
+                            else:
+                                self._read_buffer.put(byte)
+                    elif mode == M_IAC_SEEN:
+                        if byte == IAC:
+                            # interpret as command doubled -> insert character
+                            # itself
+                            if suboption is not None:
+                                suboption += IAC
+                            else:
+                                self._read_buffer.put(IAC)
+                            mode = M_NORMAL
+                        elif byte == SB:
+                            # sub option start
+                            suboption = bytearray()
+                            mode = M_NORMAL
+                        elif byte == SE:
+                            # sub option end -> process it now
+                            self._telnet_process_subnegotiation(bytes(suboption))
+                            suboption = None
+                            mode = M_NORMAL
+                        elif byte in (DO, DONT, WILL, WONT):
+                            # negotiation
+                            telnet_command = byte
+                            mode = M_NEGOTIATE
+                        else:
+                            # other telnet commands
+                            self._telnet_process_command(byte)
+                            mode = M_NORMAL
+                    elif mode == M_NEGOTIATE:  # DO, DONT, WILL, WONT was received, option now following
+                        self._telnet_negotiate_option(telnet_command, byte)
+                        mode = M_NORMAL
+        finally:
+            if self.logger:
+                self.logger.debug("read thread terminated")
+
+    # - incoming telnet commands and options
+
+    def _telnet_process_command(self, command):
+        """Process commands other than DO, DONT, WILL, WONT."""
+        # Currently none. RFC2217 only uses negotiation and subnegotiation.
+        if self.logger:
+            self.logger.warning("ignoring Telnet command: {!r}".format(command))
+
+    def _telnet_negotiate_option(self, command, option):
+        """Process incoming DO, DONT, WILL, WONT."""
+        # check our registered telnet options and forward command to them
+        # they know themselves if they have to answer or not
+        known = False
+        for item in self._telnet_options:
+            # can have more than one match! as some options are duplicated for
+            # 'us' and 'them'
+            if item.option == option:
+                item.process_incoming(command)
+                known = True
+        if not known:
+            # handle unknown options
+            # only answer to positive requests and deny them
+            if command == WILL or command == DO:
+                self.telnet_send_option((DONT if command == WILL else WONT), option)
+                if self.logger:
+                    self.logger.warning("rejected Telnet option: {!r}".format(option))
+
+    def _telnet_process_subnegotiation(self, suboption):
+        """Process subnegotiation, the data between IAC SB and IAC SE."""
+        if suboption[0:1] == COM_PORT_OPTION:
+            if suboption[1:2] == SERVER_NOTIFY_LINESTATE and len(suboption) >= 3:
+                self._linestate = ord(suboption[2:3])  # ensure it is a number
+                if self.logger:
+                    self.logger.info("NOTIFY_LINESTATE: {}".format(self._linestate))
+            elif suboption[1:2] == SERVER_NOTIFY_MODEMSTATE and len(suboption) >= 3:
+                self._modemstate = ord(suboption[2:3])  # ensure it is a number
+                if self.logger:
+                    self.logger.info("NOTIFY_MODEMSTATE: {}".format(self._modemstate))
+                # update time when we think that a poll would make sense
+                self._modemstate_timeout.restart(0.3)
+            elif suboption[1:2] == FLOWCONTROL_SUSPEND:
+                self._remote_suspend_flow = True
+            elif suboption[1:2] == FLOWCONTROL_RESUME:
+                self._remote_suspend_flow = False
+            else:
+                for item in self._rfc2217_options.values():
+                    if item.ack_option == suboption[1:2]:
+                        #~ print "processing COM_PORT_OPTION: %r" % list(suboption[1:])
+                        item.check_answer(bytes(suboption[2:]))
+                        break
+                else:
+                    if self.logger:
+                        self.logger.warning("ignoring COM_PORT_OPTION: {!r}".format(suboption))
+        else:
+            if self.logger:
+                self.logger.warning("ignoring subnegotiation: {!r}".format(suboption))
+
+    # - outgoing telnet commands and options
+
+    def _internal_raw_write(self, data):
+        """internal socket write with no data escaping. used to send telnet stuff."""
+        with self._write_lock:
+            self._socket.sendall(data)
+
+    def telnet_send_option(self, action, option):
+        """Send DO, DONT, WILL, WONT."""
+        self._internal_raw_write(IAC + action + option)
+
+    def rfc2217_send_subnegotiation(self, option, value=b''):
+        """Subnegotiation of RFC2217 parameters."""
+        value = value.replace(IAC, IAC_DOUBLED)
+        self._internal_raw_write(IAC + SB + COM_PORT_OPTION + option + value + IAC + SE)
+
+    def rfc2217_send_purge(self, value):
+        """\
+        Send purge request to the remote.
+        (PURGE_RECEIVE_BUFFER / PURGE_TRANSMIT_BUFFER / PURGE_BOTH_BUFFERS)
+        """
+        item = self._rfc2217_options['purge']
+        item.set(value)  # transmit desired purge type
+        item.wait(self._network_timeout)  # wait for acknowledge from the server
+
+    def rfc2217_set_control(self, value):
+        """transmit change of control line to remote"""
+        item = self._rfc2217_options['control']
+        item.set(value)  # transmit desired control type
+        if self._ignore_set_control_answer:
+            # answers are ignored when option is set. compatibility mode for
+            # servers that answer, but not the expected one... (or no answer
+            # at all) i.e. sredird
+            time.sleep(0.1)  # this helps getting the unit tests passed
+        else:
+            item.wait(self._network_timeout)  # wait for acknowledge from the server
+
+    def rfc2217_flow_server_ready(self):
+        """\
+        check if server is ready to receive data. block for some time when
+        not.
+        """
+        #~ if self._remote_suspend_flow:
+        #~     wait---
+
+    def get_modem_state(self):
+        """\
+        get last modem state (cached value. If value is "old", request a new
+        one. This cache helps that we don't issue to many requests when e.g. all
+        status lines, one after the other is queried by the user (CTS, DSR
+        etc.)
+        """
+        # active modem state polling enabled? is the value fresh enough?
+        if self._poll_modem_state and self._modemstate_timeout.expired():
+            if self.logger:
+                self.logger.debug('polling modem state')
+            # when it is older, request an update
+            self.rfc2217_send_subnegotiation(NOTIFY_MODEMSTATE)
+            timeout = Timeout(self._network_timeout)
+            while not timeout.expired():
+                time.sleep(0.05)    # prevent 100% CPU load
+                # when expiration time is updated, it means that there is a new
+                # value
+                if not self._modemstate_timeout.expired():
+                    break
+            else:
+                if self.logger:
+                    self.logger.warning('poll for modem state failed')
+            # even when there is a timeout, do not generate an error just
+            # return the last known value. this way we can support buggy
+            # servers that do not respond to polls, but send automatic
+            # updates.
+        if self._modemstate is not None:
+            if self.logger:
+                self.logger.debug('using cached modem state')
+            return self._modemstate
+        else:
+            # never received a notification from the server
+            raise SerialException("remote sends no NOTIFY_MODEMSTATE")
+
+
+#############################################################################
+# The following is code that helps implementing an RFC 2217 server.
+
+class PortManager(object):
+    """\
+    This class manages the state of Telnet and RFC 2217. It needs a serial
+    instance and a connection to work with. Connection is expected to implement
+    a (thread safe) write function, that writes the string to the network.
+    """
+
+    def __init__(self, serial_port, connection, logger=None):
+        self.serial = serial_port
+        self.connection = connection
+        self.logger = logger
+        self._client_is_rfc2217 = False
+
+        # filter state machine
+        self.mode = M_NORMAL
+        self.suboption = None
+        self.telnet_command = None
+
+        # states for modem/line control events
+        self.modemstate_mask = 255
+        self.last_modemstate = None
+        self.linstate_mask = 0
+
+        # all supported telnet options
+        self._telnet_options = [
+            TelnetOption(self, 'ECHO', ECHO, WILL, WONT, DO, DONT, REQUESTED),
+            TelnetOption(self, 'we-SGA', SGA, WILL, WONT, DO, DONT, REQUESTED),
+            TelnetOption(self, 'they-SGA', SGA, DO, DONT, WILL, WONT, INACTIVE),
+            TelnetOption(self, 'we-BINARY', BINARY, WILL, WONT, DO, DONT, INACTIVE),
+            TelnetOption(self, 'they-BINARY', BINARY, DO, DONT, WILL, WONT, REQUESTED),
+            TelnetOption(self, 'we-RFC2217', COM_PORT_OPTION, WILL, WONT, DO, DONT, REQUESTED, self._client_ok),
+            TelnetOption(self, 'they-RFC2217', COM_PORT_OPTION, DO, DONT, WILL, WONT, INACTIVE, self._client_ok),
+        ]
+
+        # negotiate Telnet/RFC2217 -> send initial requests
+        if self.logger:
+            self.logger.debug("requesting initial Telnet/RFC 2217 options")
+        for option in self._telnet_options:
+            if option.state is REQUESTED:
+                self.telnet_send_option(option.send_yes, option.option)
+        # issue 1st modem state notification
+
+    def _client_ok(self):
+        """\
+        callback of telnet option. It gets called when option is activated.
+        This one here is used to detect when the client agrees on RFC 2217. A
+        flag is set so that other functions like check_modem_lines know if the
+        client is OK.
+        """
+        # The callback is used for we and they so if one party agrees, we're
+        # already happy. it seems not all servers do the negotiation correctly
+        # and i guess there are incorrect clients too.. so be happy if client
+        # answers one or the other positively.
+        self._client_is_rfc2217 = True
+        if self.logger:
+            self.logger.info("client accepts RFC 2217")
+        # this is to ensure that the client gets a notification, even if there
+        # was no change
+        self.check_modem_lines(force_notification=True)
+
+    # - outgoing telnet commands and options
+
+    def telnet_send_option(self, action, option):
+        """Send DO, DONT, WILL, WONT."""
+        self.connection.write(IAC + action + option)
+
+    def rfc2217_send_subnegotiation(self, option, value=b''):
+        """Subnegotiation of RFC 2217 parameters."""
+        value = value.replace(IAC, IAC_DOUBLED)
+        self.connection.write(IAC + SB + COM_PORT_OPTION + option + value + IAC + SE)
+
+    # - check modem lines, needs to be called periodically from user to
+    # establish polling
+
+    def check_modem_lines(self, force_notification=False):
+        """\
+        read control lines from serial port and compare the last value sent to remote.
+        send updates on changes.
+        """
+        modemstate = (
+            (self.serial.cts and MODEMSTATE_MASK_CTS) |
+            (self.serial.dsr and MODEMSTATE_MASK_DSR) |
+            (self.serial.ri and MODEMSTATE_MASK_RI) |
+            (self.serial.cd and MODEMSTATE_MASK_CD))
+        # check what has changed
+        deltas = modemstate ^ (self.last_modemstate or 0)  # when last is None -> 0
+        if deltas & MODEMSTATE_MASK_CTS:
+            modemstate |= MODEMSTATE_MASK_CTS_CHANGE
+        if deltas & MODEMSTATE_MASK_DSR:
+            modemstate |= MODEMSTATE_MASK_DSR_CHANGE
+        if deltas & MODEMSTATE_MASK_RI:
+            modemstate |= MODEMSTATE_MASK_RI_CHANGE
+        if deltas & MODEMSTATE_MASK_CD:
+            modemstate |= MODEMSTATE_MASK_CD_CHANGE
+        # if new state is different and the mask allows this change, send
+        # notification. suppress notifications when client is not rfc2217
+        if modemstate != self.last_modemstate or force_notification:
+            if (self._client_is_rfc2217 and (modemstate & self.modemstate_mask)) or force_notification:
+                self.rfc2217_send_subnegotiation(
+                    SERVER_NOTIFY_MODEMSTATE,
+                    to_bytes([modemstate & self.modemstate_mask]))
+                if self.logger:
+                    self.logger.info("NOTIFY_MODEMSTATE: {}".format(modemstate))
+            # save last state, but forget about deltas.
+            # otherwise it would also notify about changing deltas which is
+            # probably not very useful
+            self.last_modemstate = modemstate & 0xf0
+
+    # - outgoing data escaping
+
+    def escape(self, data):
+        """\
+        This generator function is for the user. All outgoing data has to be
+        properly escaped, so that no IAC character in the data stream messes up
+        the Telnet state machine in the server.
+
+        socket.sendall(escape(data))
+        """
+        for byte in iterbytes(data):
+            if byte == IAC:
+                yield IAC
+                yield IAC
+            else:
+                yield byte
+
+    # - incoming data filter
+
+    def filter(self, data):
+        """\
+        Handle a bunch of incoming bytes. This is a generator. It will yield
+        all characters not of interest for Telnet/RFC 2217.
+
+        The idea is that the reader thread pushes data from the socket through
+        this filter:
+
+        for byte in filter(socket.recv(1024)):
+            # do things like CR/LF conversion/whatever
+            # and write data to the serial port
+            serial.write(byte)
+
+        (socket error handling code left as exercise for the reader)
+        """
+        for byte in iterbytes(data):
+            if self.mode == M_NORMAL:
+                # interpret as command or as data
+                if byte == IAC:
+                    self.mode = M_IAC_SEEN
+                else:
+                    # store data in sub option buffer or pass it to our
+                    # consumer depending on state
+                    if self.suboption is not None:
+                        self.suboption += byte
+                    else:
+                        yield byte
+            elif self.mode == M_IAC_SEEN:
+                if byte == IAC:
+                    # interpret as command doubled -> insert character
+                    # itself
+                    if self.suboption is not None:
+                        self.suboption += byte
+                    else:
+                        yield byte
+                    self.mode = M_NORMAL
+                elif byte == SB:
+                    # sub option start
+                    self.suboption = bytearray()
+                    self.mode = M_NORMAL
+                elif byte == SE:
+                    # sub option end -> process it now
+                    self._telnet_process_subnegotiation(bytes(self.suboption))
+                    self.suboption = None
+                    self.mode = M_NORMAL
+                elif byte in (DO, DONT, WILL, WONT):
+                    # negotiation
+                    self.telnet_command = byte
+                    self.mode = M_NEGOTIATE
+                else:
+                    # other telnet commands
+                    self._telnet_process_command(byte)
+                    self.mode = M_NORMAL
+            elif self.mode == M_NEGOTIATE:  # DO, DONT, WILL, WONT was received, option now following
+                self._telnet_negotiate_option(self.telnet_command, byte)
+                self.mode = M_NORMAL
+
+    # - incoming telnet commands and options
+
+    def _telnet_process_command(self, command):
+        """Process commands other than DO, DONT, WILL, WONT."""
+        # Currently none. RFC2217 only uses negotiation and subnegotiation.
+        if self.logger:
+            self.logger.warning("ignoring Telnet command: {!r}".format(command))
+
+    def _telnet_negotiate_option(self, command, option):
+        """Process incoming DO, DONT, WILL, WONT."""
+        # check our registered telnet options and forward command to them
+        # they know themselves if they have to answer or not
+        known = False
+        for item in self._telnet_options:
+            # can have more than one match! as some options are duplicated for
+            # 'us' and 'them'
+            if item.option == option:
+                item.process_incoming(command)
+                known = True
+        if not known:
+            # handle unknown options
+            # only answer to positive requests and deny them
+            if command == WILL or command == DO:
+                self.telnet_send_option((DONT if command == WILL else WONT), option)
+                if self.logger:
+                    self.logger.warning("rejected Telnet option: {!r}".format(option))
+
+    def _telnet_process_subnegotiation(self, suboption):
+        """Process subnegotiation, the data between IAC SB and IAC SE."""
+        if suboption[0:1] == COM_PORT_OPTION:
+            if self.logger:
+                self.logger.debug('received COM_PORT_OPTION: {!r}'.format(suboption))
+            if suboption[1:2] == SET_BAUDRATE:
+                backup = self.serial.baudrate
+                try:
+                    (baudrate,) = struct.unpack(b"!I", suboption[2:6])
+                    if baudrate != 0:
+                        self.serial.baudrate = baudrate
+                except ValueError as e:
+                    if self.logger:
+                        self.logger.error("failed to set baud rate: {}".format(e))
+                    self.serial.baudrate = backup
+                else:
+                    if self.logger:
+                        self.logger.info("{} baud rate: {}".format('set' if baudrate else 'get', self.serial.baudrate))
+                self.rfc2217_send_subnegotiation(SERVER_SET_BAUDRATE, struct.pack(b"!I", self.serial.baudrate))
+            elif suboption[1:2] == SET_DATASIZE:
+                backup = self.serial.bytesize
+                try:
+                    (datasize,) = struct.unpack(b"!B", suboption[2:3])
+                    if datasize != 0:
+                        self.serial.bytesize = datasize
+                except ValueError as e:
+                    if self.logger:
+                        self.logger.error("failed to set data size: {}".format(e))
+                    self.serial.bytesize = backup
+                else:
+                    if self.logger:
+                        self.logger.info("{} data size: {}".format('set' if datasize else 'get', self.serial.bytesize))
+                self.rfc2217_send_subnegotiation(SERVER_SET_DATASIZE, struct.pack(b"!B", self.serial.bytesize))
+            elif suboption[1:2] == SET_PARITY:
+                backup = self.serial.parity
+                try:
+                    parity = struct.unpack(b"!B", suboption[2:3])[0]
+                    if parity != 0:
+                        self.serial.parity = RFC2217_REVERSE_PARITY_MAP[parity]
+                except ValueError as e:
+                    if self.logger:
+                        self.logger.error("failed to set parity: {}".format(e))
+                    self.serial.parity = backup
+                else:
+                    if self.logger:
+                        self.logger.info("{} parity: {}".format('set' if parity else 'get', self.serial.parity))
+                self.rfc2217_send_subnegotiation(
+                    SERVER_SET_PARITY,
+                    struct.pack(b"!B", RFC2217_PARITY_MAP[self.serial.parity]))
+            elif suboption[1:2] == SET_STOPSIZE:
+                backup = self.serial.stopbits
+                try:
+                    stopbits = struct.unpack(b"!B", suboption[2:3])[0]
+                    if stopbits != 0:
+                        self.serial.stopbits = RFC2217_REVERSE_STOPBIT_MAP[stopbits]
+                except ValueError as e:
+                    if self.logger:
+                        self.logger.error("failed to set stop bits: {}".format(e))
+                    self.serial.stopbits = backup
+                else:
+                    if self.logger:
+                        self.logger.info("{} stop bits: {}".format('set' if stopbits else 'get', self.serial.stopbits))
+                self.rfc2217_send_subnegotiation(
+                    SERVER_SET_STOPSIZE,
+                    struct.pack(b"!B", RFC2217_STOPBIT_MAP[self.serial.stopbits]))
+            elif suboption[1:2] == SET_CONTROL:
+                if suboption[2:3] == SET_CONTROL_REQ_FLOW_SETTING:
+                    if self.serial.xonxoff:
+                        self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_USE_SW_FLOW_CONTROL)
+                    elif self.serial.rtscts:
+                        self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_USE_HW_FLOW_CONTROL)
+                    else:
+                        self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_USE_NO_FLOW_CONTROL)
+                elif suboption[2:3] == SET_CONTROL_USE_NO_FLOW_CONTROL:
+                    self.serial.xonxoff = False
+                    self.serial.rtscts = False
+                    if self.logger:
+                        self.logger.info("changed flow control to None")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_USE_NO_FLOW_CONTROL)
+                elif suboption[2:3] == SET_CONTROL_USE_SW_FLOW_CONTROL:
+                    self.serial.xonxoff = True
+                    if self.logger:
+                        self.logger.info("changed flow control to XON/XOFF")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_USE_SW_FLOW_CONTROL)
+                elif suboption[2:3] == SET_CONTROL_USE_HW_FLOW_CONTROL:
+                    self.serial.rtscts = True
+                    if self.logger:
+                        self.logger.info("changed flow control to RTS/CTS")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_USE_HW_FLOW_CONTROL)
+                elif suboption[2:3] == SET_CONTROL_REQ_BREAK_STATE:
+                    if self.logger:
+                        self.logger.warning("requested break state - not implemented")
+                    pass  # XXX needs cached value
+                elif suboption[2:3] == SET_CONTROL_BREAK_ON:
+                    self.serial.break_condition = True
+                    if self.logger:
+                        self.logger.info("changed BREAK to active")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_BREAK_ON)
+                elif suboption[2:3] == SET_CONTROL_BREAK_OFF:
+                    self.serial.break_condition = False
+                    if self.logger:
+                        self.logger.info("changed BREAK to inactive")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_BREAK_OFF)
+                elif suboption[2:3] == SET_CONTROL_REQ_DTR:
+                    if self.logger:
+                        self.logger.warning("requested DTR state - not implemented")
+                    pass  # XXX needs cached value
+                elif suboption[2:3] == SET_CONTROL_DTR_ON:
+                    self.serial.dtr = True
+                    if self.logger:
+                        self.logger.info("changed DTR to active")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_DTR_ON)
+                elif suboption[2:3] == SET_CONTROL_DTR_OFF:
+                    self.serial.dtr = False
+                    if self.logger:
+                        self.logger.info("changed DTR to inactive")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_DTR_OFF)
+                elif suboption[2:3] == SET_CONTROL_REQ_RTS:
+                    if self.logger:
+                        self.logger.warning("requested RTS state - not implemented")
+                    pass  # XXX needs cached value
+                    #~ self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_RTS_ON)
+                elif suboption[2:3] == SET_CONTROL_RTS_ON:
+                    self.serial.rts = True
+                    if self.logger:
+                        self.logger.info("changed RTS to active")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_RTS_ON)
+                elif suboption[2:3] == SET_CONTROL_RTS_OFF:
+                    self.serial.rts = False
+                    if self.logger:
+                        self.logger.info("changed RTS to inactive")
+                    self.rfc2217_send_subnegotiation(SERVER_SET_CONTROL, SET_CONTROL_RTS_OFF)
+                #~ elif suboption[2:3] == SET_CONTROL_REQ_FLOW_SETTING_IN:
+                #~ elif suboption[2:3] == SET_CONTROL_USE_NO_FLOW_CONTROL_IN:
+                #~ elif suboption[2:3] == SET_CONTROL_USE_SW_FLOW_CONTOL_IN:
+                #~ elif suboption[2:3] == SET_CONTROL_USE_HW_FLOW_CONTOL_IN:
+                #~ elif suboption[2:3] == SET_CONTROL_USE_DCD_FLOW_CONTROL:
+                #~ elif suboption[2:3] == SET_CONTROL_USE_DTR_FLOW_CONTROL:
+                #~ elif suboption[2:3] == SET_CONTROL_USE_DSR_FLOW_CONTROL:
+            elif suboption[1:2] == NOTIFY_LINESTATE:
+                # client polls for current state
+                self.rfc2217_send_subnegotiation(
+                    SERVER_NOTIFY_LINESTATE,
+                    to_bytes([0]))   # sorry, nothing like that implemented
+            elif suboption[1:2] == NOTIFY_MODEMSTATE:
+                if self.logger:
+                    self.logger.info("request for modem state")
+                # client polls for current state
+                self.check_modem_lines(force_notification=True)
+            elif suboption[1:2] == FLOWCONTROL_SUSPEND:
+                if self.logger:
+                    self.logger.info("suspend")
+                self._remote_suspend_flow = True
+            elif suboption[1:2] == FLOWCONTROL_RESUME:
+                if self.logger:
+                    self.logger.info("resume")
+                self._remote_suspend_flow = False
+            elif suboption[1:2] == SET_LINESTATE_MASK:
+                self.linstate_mask = ord(suboption[2:3])  # ensure it is a number
+                if self.logger:
+                    self.logger.info("line state mask: 0x{:02x}".format(self.linstate_mask))
+            elif suboption[1:2] == SET_MODEMSTATE_MASK:
+                self.modemstate_mask = ord(suboption[2:3])  # ensure it is a number
+                if self.logger:
+                    self.logger.info("modem state mask: 0x{:02x}".format(self.modemstate_mask))
+            elif suboption[1:2] == PURGE_DATA:
+                if suboption[2:3] == PURGE_RECEIVE_BUFFER:
+                    self.serial.reset_input_buffer()
+                    if self.logger:
+                        self.logger.info("purge in")
+                    self.rfc2217_send_subnegotiation(SERVER_PURGE_DATA, PURGE_RECEIVE_BUFFER)
+                elif suboption[2:3] == PURGE_TRANSMIT_BUFFER:
+                    self.serial.reset_output_buffer()
+                    if self.logger:
+                        self.logger.info("purge out")
+                    self.rfc2217_send_subnegotiation(SERVER_PURGE_DATA, PURGE_TRANSMIT_BUFFER)
+                elif suboption[2:3] == PURGE_BOTH_BUFFERS:
+                    self.serial.reset_input_buffer()
+                    self.serial.reset_output_buffer()
+                    if self.logger:
+                        self.logger.info("purge both")
+                    self.rfc2217_send_subnegotiation(SERVER_PURGE_DATA, PURGE_BOTH_BUFFERS)
+                else:
+                    if self.logger:
+                        self.logger.error("undefined PURGE_DATA: {!r}".format(list(suboption[2:])))
+            else:
+                if self.logger:
+                    self.logger.error("undefined COM_PORT_OPTION: {!r}".format(list(suboption[1:])))
+        else:
+            if self.logger:
+                self.logger.warning("unknown subnegotiation: {!r}".format(suboption))
+
+
+# simple client test
+if __name__ == '__main__':
+    import sys
+    s = Serial('rfc2217://localhost:7000', 115200)
+    sys.stdout.write('{}\n'.format(s))
+
+    sys.stdout.write("write...\n")
+    s.write(b"hello\n")
+    s.flush()
+    sys.stdout.write("read: {}\n".format(s.read(5)))
+    s.close()
diff --git a/modbus/serial/rs485.py b/modbus/serial/rs485.py
new file mode 100644
index 00000000..d7aff6f6
--- /dev/null
+++ b/modbus/serial/rs485.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python
+
+# RS485 support
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+"""\
+The settings for RS485 are stored in a dedicated object that can be applied to
+serial ports (where supported).
+NOTE: Some implementations may only support a subset of the settings.
+"""
+
+from __future__ import absolute_import
+
+import time
+import serial
+
+
+class RS485Settings(object):
+    def __init__(
+            self,
+            rts_level_for_tx=True,
+            rts_level_for_rx=False,
+            loopback=False,
+            delay_before_tx=None,
+            delay_before_rx=None):
+        self.rts_level_for_tx = rts_level_for_tx
+        self.rts_level_for_rx = rts_level_for_rx
+        self.loopback = loopback
+        self.delay_before_tx = delay_before_tx
+        self.delay_before_rx = delay_before_rx
+
+
+class RS485(serial.Serial):
+    """\
+    A subclass that replaces the write method with one that toggles RTS
+    according to the RS485 settings.
+
+    NOTE: This may work unreliably on some serial ports (control signals not
+          synchronized or delayed compared to data). Using delays may be
+          unreliable (varying times, larger than expected) as the OS may not
+          support very fine grained delays (no smaller than in the order of
+          tens of milliseconds).
+
+    NOTE: Some implementations support this natively. Better performance
+          can be expected when the native version is used.
+
+    NOTE: The loopback property is ignored by this implementation. The actual
+          behavior depends on the used hardware.
+
+    Usage:
+
+        ser = RS485(...)
+        ser.rs485_mode = RS485Settings(...)
+        ser.write(b'hello')
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(RS485, self).__init__(*args, **kwargs)
+        self._alternate_rs485_settings = None
+
+    def write(self, b):
+        """Write to port, controlling RTS before and after transmitting."""
+        if self._alternate_rs485_settings is not None:
+            # apply level for TX and optional delay
+            self.setRTS(self._alternate_rs485_settings.rts_level_for_tx)
+            if self._alternate_rs485_settings.delay_before_tx is not None:
+                time.sleep(self._alternate_rs485_settings.delay_before_tx)
+            # write and wait for data to be written
+            super(RS485, self).write(b)
+            super(RS485, self).flush()
+            # optional delay and apply level for RX
+            if self._alternate_rs485_settings.delay_before_rx is not None:
+                time.sleep(self._alternate_rs485_settings.delay_before_rx)
+            self.setRTS(self._alternate_rs485_settings.rts_level_for_rx)
+        else:
+            super(RS485, self).write(b)
+
+    # redirect where the property stores the settings so that underlying Serial
+    # instance does not see them
+    @property
+    def rs485_mode(self):
+        """\
+        Enable RS485 mode and apply new settings, set to None to disable.
+        See serial.rs485.RS485Settings for more info about the value.
+        """
+        return self._alternate_rs485_settings
+
+    @rs485_mode.setter
+    def rs485_mode(self, rs485_settings):
+        self._alternate_rs485_settings = rs485_settings
diff --git a/modbus/serial/serialcli.py b/modbus/serial/serialcli.py
new file mode 100644
index 00000000..4614736e
--- /dev/null
+++ b/modbus/serial/serialcli.py
@@ -0,0 +1,253 @@
+#! python
+#
+# Backend for .NET/Mono (IronPython), .NET >= 2
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2008-2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+import System
+import System.IO.Ports
+from serial.serialutil import *
+
+# must invoke function with byte array, make a helper to convert strings
+# to byte arrays
+sab = System.Array[System.Byte]
+
+
+def as_byte_array(string):
+    return sab([ord(x) for x in string])  # XXX will require adaption when run with a 3.x compatible IronPython
+
+
+class Serial(SerialBase):
+    """Serial port implementation for .NET/Mono."""
+
+    BAUDRATES = (50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800,
+                 9600, 19200, 38400, 57600, 115200)
+
+    def open(self):
+        """\
+        Open port with current settings. This may throw a SerialException
+        if the port cannot be opened.
+        """
+        if self._port is None:
+            raise SerialException("Port must be configured before it can be used.")
+        if self.is_open:
+            raise SerialException("Port is already open.")
+        try:
+            self._port_handle = System.IO.Ports.SerialPort(self.portstr)
+        except Exception as msg:
+            self._port_handle = None
+            raise SerialException("could not open port %s: %s" % (self.portstr, msg))
+
+        # if RTS and/or DTR are not set before open, they default to True
+        if self._rts_state is None:
+            self._rts_state = True
+        if self._dtr_state is None:
+            self._dtr_state = True
+
+        self._reconfigure_port()
+        self._port_handle.Open()
+        self.is_open = True
+        if not self._dsrdtr:
+            self._update_dtr_state()
+        if not self._rtscts:
+            self._update_rts_state()
+        self.reset_input_buffer()
+
+    def _reconfigure_port(self):
+        """Set communication parameters on opened port."""
+        if not self._port_handle:
+            raise SerialException("Can only operate on a valid port handle")
+
+        #~ self._port_handle.ReceivedBytesThreshold = 1
+
+        if self._timeout is None:
+            self._port_handle.ReadTimeout = System.IO.Ports.SerialPort.InfiniteTimeout
+        else:
+            self._port_handle.ReadTimeout = int(self._timeout * 1000)
+
+        # if self._timeout != 0 and self._interCharTimeout is not None:
+            # timeouts = (int(self._interCharTimeout * 1000),) + timeouts[1:]
+
+        if self._write_timeout is None:
+            self._port_handle.WriteTimeout = System.IO.Ports.SerialPort.InfiniteTimeout
+        else:
+            self._port_handle.WriteTimeout = int(self._write_timeout * 1000)
+
+        # Setup the connection info.
+        try:
+            self._port_handle.BaudRate = self._baudrate
+        except IOError as e:
+            # catch errors from illegal baudrate settings
+            raise ValueError(str(e))
+
+        if self._bytesize == FIVEBITS:
+            self._port_handle.DataBits = 5
+        elif self._bytesize == SIXBITS:
+            self._port_handle.DataBits = 6
+        elif self._bytesize == SEVENBITS:
+            self._port_handle.DataBits = 7
+        elif self._bytesize == EIGHTBITS:
+            self._port_handle.DataBits = 8
+        else:
+            raise ValueError("Unsupported number of data bits: %r" % self._bytesize)
+
+        if self._parity == PARITY_NONE:
+            self._port_handle.Parity = getattr(System.IO.Ports.Parity, 'None')  # reserved keyword in Py3k
+        elif self._parity == PARITY_EVEN:
+            self._port_handle.Parity = System.IO.Ports.Parity.Even
+        elif self._parity == PARITY_ODD:
+            self._port_handle.Parity = System.IO.Ports.Parity.Odd
+        elif self._parity == PARITY_MARK:
+            self._port_handle.Parity = System.IO.Ports.Parity.Mark
+        elif self._parity == PARITY_SPACE:
+            self._port_handle.Parity = System.IO.Ports.Parity.Space
+        else:
+            raise ValueError("Unsupported parity mode: %r" % self._parity)
+
+        if self._stopbits == STOPBITS_ONE:
+            self._port_handle.StopBits = System.IO.Ports.StopBits.One
+        elif self._stopbits == STOPBITS_ONE_POINT_FIVE:
+            self._port_handle.StopBits = System.IO.Ports.StopBits.OnePointFive
+        elif self._stopbits == STOPBITS_TWO:
+            self._port_handle.StopBits = System.IO.Ports.StopBits.Two
+        else:
+            raise ValueError("Unsupported number of stop bits: %r" % self._stopbits)
+
+        if self._rtscts and self._xonxoff:
+            self._port_handle.Handshake = System.IO.Ports.Handshake.RequestToSendXOnXOff
+        elif self._rtscts:
+            self._port_handle.Handshake = System.IO.Ports.Handshake.RequestToSend
+        elif self._xonxoff:
+            self._port_handle.Handshake = System.IO.Ports.Handshake.XOnXOff
+        else:
+            self._port_handle.Handshake = getattr(System.IO.Ports.Handshake, 'None')   # reserved keyword in Py3k
+
+    #~ def __del__(self):
+        #~ self.close()
+
+    def close(self):
+        """Close port"""
+        if self.is_open:
+            if self._port_handle:
+                try:
+                    self._port_handle.Close()
+                except System.IO.Ports.InvalidOperationException:
+                    # ignore errors. can happen for unplugged USB serial devices
+                    pass
+                self._port_handle = None
+            self.is_open = False
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    @property
+    def in_waiting(self):
+        """Return the number of characters currently in the input buffer."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return self._port_handle.BytesToRead
+
+    def read(self, size=1):
+        """\
+        Read size bytes from the serial port. If a timeout is set it may
+        return less characters as requested. With no timeout it will block
+        until the requested number of bytes is read.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        # must use single byte reads as this is the only way to read
+        # without applying encodings
+        data = bytearray()
+        while size:
+            try:
+                data.append(self._port_handle.ReadByte())
+            except System.TimeoutException:
+                break
+            else:
+                size -= 1
+        return bytes(data)
+
+    def write(self, data):
+        """Output the given string over the serial port."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        #~ if not isinstance(data, (bytes, bytearray)):
+            #~ raise TypeError('expected %s or bytearray, got %s' % (bytes, type(data)))
+        try:
+            # must call overloaded method with byte array argument
+            # as this is the only one not applying encodings
+            self._port_handle.Write(as_byte_array(data), 0, len(data))
+        except System.TimeoutException:
+            raise SerialTimeoutException('Write timeout')
+        return len(data)
+
+    def reset_input_buffer(self):
+        """Clear input buffer, discarding all that is in the buffer."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._port_handle.DiscardInBuffer()
+
+    def reset_output_buffer(self):
+        """\
+        Clear output buffer, aborting the current output and
+        discarding all that is in the buffer.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._port_handle.DiscardOutBuffer()
+
+    def _update_break_state(self):
+        """
+        Set break: Controls TXD. When active, to transmitting is possible.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._port_handle.BreakState = bool(self._break_state)
+
+    def _update_rts_state(self):
+        """Set terminal status line: Request To Send"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._port_handle.RtsEnable = bool(self._rts_state)
+
+    def _update_dtr_state(self):
+        """Set terminal status line: Data Terminal Ready"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._port_handle.DtrEnable = bool(self._dtr_state)
+
+    @property
+    def cts(self):
+        """Read terminal status line: Clear To Send"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return self._port_handle.CtsHolding
+
+    @property
+    def dsr(self):
+        """Read terminal status line: Data Set Ready"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return self._port_handle.DsrHolding
+
+    @property
+    def ri(self):
+        """Read terminal status line: Ring Indicator"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        #~ return self._port_handle.XXX
+        return False  # XXX an error would be better
+
+    @property
+    def cd(self):
+        """Read terminal status line: Carrier Detect"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        return self._port_handle.CDHolding
+
+    # - - platform specific - - - -
+    # none
diff --git a/modbus/serial/serialjava.py b/modbus/serial/serialjava.py
new file mode 100644
index 00000000..0789a780
--- /dev/null
+++ b/modbus/serial/serialjava.py
@@ -0,0 +1,251 @@
+#!jython
+#
+# Backend Jython with JavaComm
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2002-2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+from serial.serialutil import *
+
+
+def my_import(name):
+    mod = __import__(name)
+    components = name.split('.')
+    for comp in components[1:]:
+        mod = getattr(mod, comp)
+    return mod
+
+
+def detect_java_comm(names):
+    """try given list of modules and return that imports"""
+    for name in names:
+        try:
+            mod = my_import(name)
+            mod.SerialPort
+            return mod
+        except (ImportError, AttributeError):
+            pass
+    raise ImportError("No Java Communications API implementation found")
+
+
+# Java Communications API implementations
+# http://mho.republika.pl/java/comm/
+
+comm = detect_java_comm([
+    'javax.comm',  # Sun/IBM
+    'gnu.io',      # RXTX
+])
+
+
+def device(portnumber):
+    """Turn a port number into a device name"""
+    enum = comm.CommPortIdentifier.getPortIdentifiers()
+    ports = []
+    while enum.hasMoreElements():
+        el = enum.nextElement()
+        if el.getPortType() == comm.CommPortIdentifier.PORT_SERIAL:
+            ports.append(el)
+    return ports[portnumber].getName()
+
+
+class Serial(SerialBase):
+    """\
+    Serial port class, implemented with Java Communications API and
+    thus usable with jython and the appropriate java extension.
+    """
+
+    def open(self):
+        """\
+        Open port with current settings. This may throw a SerialException
+        if the port cannot be opened.
+        """
+        if self._port is None:
+            raise SerialException("Port must be configured before it can be used.")
+        if self.is_open:
+            raise SerialException("Port is already open.")
+        if type(self._port) == type(''):      # strings are taken directly
+            portId = comm.CommPortIdentifier.getPortIdentifier(self._port)
+        else:
+            portId = comm.CommPortIdentifier.getPortIdentifier(device(self._port))     # numbers are transformed to a comport id obj
+        try:
+            self.sPort = portId.open("python serial module", 10)
+        except Exception as msg:
+            self.sPort = None
+            raise SerialException("Could not open port: %s" % msg)
+        self._reconfigurePort()
+        self._instream = self.sPort.getInputStream()
+        self._outstream = self.sPort.getOutputStream()
+        self.is_open = True
+
+    def _reconfigurePort(self):
+        """Set communication parameters on opened port."""
+        if not self.sPort:
+            raise SerialException("Can only operate on a valid port handle")
+
+        self.sPort.enableReceiveTimeout(30)
+        if self._bytesize == FIVEBITS:
+            jdatabits = comm.SerialPort.DATABITS_5
+        elif self._bytesize == SIXBITS:
+            jdatabits = comm.SerialPort.DATABITS_6
+        elif self._bytesize == SEVENBITS:
+            jdatabits = comm.SerialPort.DATABITS_7
+        elif self._bytesize == EIGHTBITS:
+            jdatabits = comm.SerialPort.DATABITS_8
+        else:
+            raise ValueError("unsupported bytesize: %r" % self._bytesize)
+
+        if self._stopbits == STOPBITS_ONE:
+            jstopbits = comm.SerialPort.STOPBITS_1
+        elif self._stopbits == STOPBITS_ONE_POINT_FIVE:
+            jstopbits = comm.SerialPort.STOPBITS_1_5
+        elif self._stopbits == STOPBITS_TWO:
+            jstopbits = comm.SerialPort.STOPBITS_2
+        else:
+            raise ValueError("unsupported number of stopbits: %r" % self._stopbits)
+
+        if self._parity == PARITY_NONE:
+            jparity = comm.SerialPort.PARITY_NONE
+        elif self._parity == PARITY_EVEN:
+            jparity = comm.SerialPort.PARITY_EVEN
+        elif self._parity == PARITY_ODD:
+            jparity = comm.SerialPort.PARITY_ODD
+        elif self._parity == PARITY_MARK:
+            jparity = comm.SerialPort.PARITY_MARK
+        elif self._parity == PARITY_SPACE:
+            jparity = comm.SerialPort.PARITY_SPACE
+        else:
+            raise ValueError("unsupported parity type: %r" % self._parity)
+
+        jflowin = jflowout = 0
+        if self._rtscts:
+            jflowin |= comm.SerialPort.FLOWCONTROL_RTSCTS_IN
+            jflowout |= comm.SerialPort.FLOWCONTROL_RTSCTS_OUT
+        if self._xonxoff:
+            jflowin |= comm.SerialPort.FLOWCONTROL_XONXOFF_IN
+            jflowout |= comm.SerialPort.FLOWCONTROL_XONXOFF_OUT
+
+        self.sPort.setSerialPortParams(self._baudrate, jdatabits, jstopbits, jparity)
+        self.sPort.setFlowControlMode(jflowin | jflowout)
+
+        if self._timeout >= 0:
+            self.sPort.enableReceiveTimeout(int(self._timeout*1000))
+        else:
+            self.sPort.disableReceiveTimeout()
+
+    def close(self):
+        """Close port"""
+        if self.is_open:
+            if self.sPort:
+                self._instream.close()
+                self._outstream.close()
+                self.sPort.close()
+                self.sPort = None
+            self.is_open = False
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    @property
+    def in_waiting(self):
+        """Return the number of characters currently in the input buffer."""
+        if not self.sPort:
+            raise PortNotOpenError()
+        return self._instream.available()
+
+    def read(self, size=1):
+        """\
+        Read size bytes from the serial port. If a timeout is set it may
+        return less characters as requested. With no timeout it will block
+        until the requested number of bytes is read.
+        """
+        if not self.sPort:
+            raise PortNotOpenError()
+        read = bytearray()
+        if size > 0:
+            while len(read) < size:
+                x = self._instream.read()
+                if x == -1:
+                    if self.timeout >= 0:
+                        break
+                else:
+                    read.append(x)
+        return bytes(read)
+
+    def write(self, data):
+        """Output the given string over the serial port."""
+        if not self.sPort:
+            raise PortNotOpenError()
+        if not isinstance(data, (bytes, bytearray)):
+            raise TypeError('expected %s or bytearray, got %s' % (bytes, type(data)))
+        self._outstream.write(data)
+        return len(data)
+
+    def reset_input_buffer(self):
+        """Clear input buffer, discarding all that is in the buffer."""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self._instream.skip(self._instream.available())
+
+    def reset_output_buffer(self):
+        """\
+        Clear output buffer, aborting the current output and
+        discarding all that is in the buffer.
+        """
+        if not self.sPort:
+            raise PortNotOpenError()
+        self._outstream.flush()
+
+    def send_break(self, duration=0.25):
+        """Send break condition. Timed, returns to idle state after given duration."""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self.sPort.sendBreak(duration*1000.0)
+
+    def _update_break_state(self):
+        """Set break: Controls TXD. When active, to transmitting is possible."""
+        if self.fd is None:
+            raise PortNotOpenError()
+        raise SerialException("The _update_break_state function is not implemented in java.")
+
+    def _update_rts_state(self):
+        """Set terminal status line: Request To Send"""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self.sPort.setRTS(self._rts_state)
+
+    def _update_dtr_state(self):
+        """Set terminal status line: Data Terminal Ready"""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self.sPort.setDTR(self._dtr_state)
+
+    @property
+    def cts(self):
+        """Read terminal status line: Clear To Send"""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self.sPort.isCTS()
+
+    @property
+    def dsr(self):
+        """Read terminal status line: Data Set Ready"""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self.sPort.isDSR()
+
+    @property
+    def ri(self):
+        """Read terminal status line: Ring Indicator"""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self.sPort.isRI()
+
+    @property
+    def cd(self):
+        """Read terminal status line: Carrier Detect"""
+        if not self.sPort:
+            raise PortNotOpenError()
+        self.sPort.isCD()
diff --git a/modbus/serial/serialposix.py b/modbus/serial/serialposix.py
new file mode 100644
index 00000000..7aceb76d
--- /dev/null
+++ b/modbus/serial/serialposix.py
@@ -0,0 +1,900 @@
+#!/usr/bin/env python
+#
+# backend for serial IO for POSIX compatible systems, like Linux, OSX
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2001-2020 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+#
+# parts based on code from Grant B. Edwards  :
+#  ftp://ftp.visi.com/users/grante/python/PosixSerial.py
+#
+# references: http://www.easysw.com/~mike/serial/serial.html
+
+# Collection of port names (was previously used by number_to_device which was
+# removed.
+# - Linux                   /dev/ttyS%d (confirmed)
+# - cygwin/win32            /dev/com%d (confirmed)
+# - openbsd (OpenBSD)       /dev/cua%02d
+# - bsd*, freebsd*          /dev/cuad%d
+# - darwin (OS X)           /dev/cuad%d
+# - netbsd                  /dev/dty%02d (NetBSD 1.6 testing by Erk)
+# - irix (IRIX)             /dev/ttyf%d (partially tested) names depending on flow control
+# - hp (HP-UX)              /dev/tty%dp0 (not tested)
+# - sunos (Solaris/SunOS)   /dev/tty%c (letters, 'a'..'z') (confirmed)
+# - aix (AIX)               /dev/tty%d
+
+
+from __future__ import absolute_import
+
+# pylint: disable=abstract-method
+import errno
+import fcntl
+import os
+import select
+import struct
+import sys
+import termios
+
+import serial
+from serial.serialutil import SerialBase, SerialException, to_bytes, \
+    PortNotOpenError, SerialTimeoutException, Timeout
+
+
+class PlatformSpecificBase(object):
+    BAUDRATE_CONSTANTS = {}
+
+    def _set_special_baudrate(self, baudrate):
+        raise NotImplementedError('non-standard baudrates are not supported on this platform')
+
+    def _set_rs485_mode(self, rs485_settings):
+        raise NotImplementedError('RS485 not supported on this platform')
+
+    def set_low_latency_mode(self, low_latency_settings):
+        raise NotImplementedError('Low latency not supported on this platform')
+
+    def _update_break_state(self):
+        """\
+        Set break: Controls TXD. When active, no transmitting is possible.
+        """
+        if self._break_state:
+            fcntl.ioctl(self.fd, TIOCSBRK)
+        else:
+            fcntl.ioctl(self.fd, TIOCCBRK)
+    
+
+# some systems support an extra flag to enable the two in POSIX unsupported
+# paritiy settings for MARK and SPACE
+CMSPAR = 0  # default, for unsupported platforms, override below
+
+# try to detect the OS so that a device can be selected...
+# this code block should supply a device() and set_special_baudrate() function
+# for the platform
+plat = sys.platform.lower()
+
+if plat[:5] == 'linux':    # Linux (confirmed)  # noqa
+    import array
+
+    # extra termios flags
+    CMSPAR = 0o10000000000  # Use "stick" (mark/space) parity
+
+    # baudrate ioctls
+    TCGETS2 = 0x802C542A
+    TCSETS2 = 0x402C542B
+    BOTHER = 0o010000
+
+    # RS485 ioctls
+    TIOCGRS485 = 0x542E
+    TIOCSRS485 = 0x542F
+    SER_RS485_ENABLED = 0b00000001
+    SER_RS485_RTS_ON_SEND = 0b00000010
+    SER_RS485_RTS_AFTER_SEND = 0b00000100
+    SER_RS485_RX_DURING_TX = 0b00010000
+
+    class PlatformSpecific(PlatformSpecificBase):
+        BAUDRATE_CONSTANTS = {
+            0:       0o000000,  # hang up
+            50:      0o000001,
+            75:      0o000002,
+            110:     0o000003,
+            134:     0o000004,
+            150:     0o000005,
+            200:     0o000006,
+            300:     0o000007,
+            600:     0o000010,
+            1200:    0o000011,
+            1800:    0o000012,
+            2400:    0o000013,
+            4800:    0o000014,
+            9600:    0o000015,
+            19200:   0o000016,
+            38400:   0o000017,
+            57600:   0o010001,
+            115200:  0o010002,
+            230400:  0o010003,
+            460800:  0o010004,
+            500000:  0o010005,
+            576000:  0o010006,
+            921600:  0o010007,
+            1000000: 0o010010,
+            1152000: 0o010011,
+            1500000: 0o010012,
+            2000000: 0o010013,
+            2500000: 0o010014,
+            3000000: 0o010015,
+            3500000: 0o010016,
+            4000000: 0o010017
+        }
+
+        def set_low_latency_mode(self, low_latency_settings):
+            buf = array.array('i', [0] * 32)
+
+            try:
+                # get serial_struct
+                fcntl.ioctl(self.fd, termios.TIOCGSERIAL, buf)
+
+                # set or unset ASYNC_LOW_LATENCY flag
+                if low_latency_settings:
+                    buf[4] |= 0x2000
+                else:
+                    buf[4] &= ~0x2000
+
+                # set serial_struct
+                fcntl.ioctl(self.fd, termios.TIOCSSERIAL, buf)
+            except IOError as e:
+                raise ValueError('Failed to update ASYNC_LOW_LATENCY flag to {}: {}'.format(low_latency_settings, e))
+
+        def _set_special_baudrate(self, baudrate):
+            # right size is 44 on x86_64, allow for some growth
+            buf = array.array('i', [0] * 64)
+            try:
+                # get serial_struct
+                fcntl.ioctl(self.fd, TCGETS2, buf)
+                # set custom speed
+                buf[2] &= ~termios.CBAUD
+                buf[2] |= BOTHER
+                buf[9] = buf[10] = baudrate
+
+                # set serial_struct
+                fcntl.ioctl(self.fd, TCSETS2, buf)
+            except IOError as e:
+                raise ValueError('Failed to set custom baud rate ({}): {}'.format(baudrate, e))
+
+        def _set_rs485_mode(self, rs485_settings):
+            buf = array.array('i', [0] * 8)  # flags, delaytx, delayrx, padding
+            try:
+                fcntl.ioctl(self.fd, TIOCGRS485, buf)
+                buf[0] |= SER_RS485_ENABLED
+                if rs485_settings is not None:
+                    if rs485_settings.loopback:
+                        buf[0] |= SER_RS485_RX_DURING_TX
+                    else:
+                        buf[0] &= ~SER_RS485_RX_DURING_TX
+                    if rs485_settings.rts_level_for_tx:
+                        buf[0] |= SER_RS485_RTS_ON_SEND
+                    else:
+                        buf[0] &= ~SER_RS485_RTS_ON_SEND
+                    if rs485_settings.rts_level_for_rx:
+                        buf[0] |= SER_RS485_RTS_AFTER_SEND
+                    else:
+                        buf[0] &= ~SER_RS485_RTS_AFTER_SEND
+                    if rs485_settings.delay_before_tx is not None:
+                        buf[1] = int(rs485_settings.delay_before_tx * 1000)
+                    if rs485_settings.delay_before_rx is not None:
+                        buf[2] = int(rs485_settings.delay_before_rx * 1000)
+                else:
+                    buf[0] = 0  # clear SER_RS485_ENABLED
+                fcntl.ioctl(self.fd, TIOCSRS485, buf)
+            except IOError as e:
+                raise ValueError('Failed to set RS485 mode: {}'.format(e))
+
+
+elif plat == 'cygwin':       # cygwin/win32 (confirmed)
+
+    class PlatformSpecific(PlatformSpecificBase):
+        BAUDRATE_CONSTANTS = {
+            128000: 0x01003,
+            256000: 0x01005,
+            500000: 0x01007,
+            576000: 0x01008,
+            921600: 0x01009,
+            1000000: 0x0100a,
+            1152000: 0x0100b,
+            1500000: 0x0100c,
+            2000000: 0x0100d,
+            2500000: 0x0100e,
+            3000000: 0x0100f
+        }
+
+
+elif plat[:6] == 'darwin':   # OS X
+    import array
+    IOSSIOSPEED = 0x80045402  # _IOW('T', 2, speed_t)
+
+    class PlatformSpecific(PlatformSpecificBase):
+        osx_version = os.uname()[2].split('.')
+        TIOCSBRK = 0x2000747B # _IO('t', 123)
+        TIOCCBRK = 0x2000747A # _IO('t', 122)
+
+        # Tiger or above can support arbitrary serial speeds
+        if int(osx_version[0]) >= 8:
+            def _set_special_baudrate(self, baudrate):
+                # use IOKit-specific call to set up high speeds
+                buf = array.array('i', [baudrate])
+                fcntl.ioctl(self.fd, IOSSIOSPEED, buf, 1)
+
+        def _update_break_state(self):
+            """\
+            Set break: Controls TXD. When active, no transmitting is possible.
+            """
+            if self._break_state:
+                fcntl.ioctl(self.fd, PlatformSpecific.TIOCSBRK)
+            else:
+                fcntl.ioctl(self.fd, PlatformSpecific.TIOCCBRK)
+
+elif plat[:3] == 'bsd' or \
+     plat[:7] == 'freebsd' or \
+     plat[:6] == 'netbsd' or \
+     plat[:7] == 'openbsd':
+
+    class ReturnBaudrate(object):
+        def __getitem__(self, key):
+            return key
+
+    class PlatformSpecific(PlatformSpecificBase):
+        # Only tested on FreeBSD:
+        # The baud rate may be passed in as
+        # a literal value.
+        BAUDRATE_CONSTANTS = ReturnBaudrate()
+
+        TIOCSBRK = 0x2000747B # _IO('t', 123)
+        TIOCCBRK = 0x2000747A # _IO('t', 122)
+
+        
+        def _update_break_state(self):
+            """\
+            Set break: Controls TXD. When active, no transmitting is possible.
+            """
+            if self._break_state:
+                fcntl.ioctl(self.fd, PlatformSpecific.TIOCSBRK)
+            else:
+                fcntl.ioctl(self.fd, PlatformSpecific.TIOCCBRK)
+
+else:
+    class PlatformSpecific(PlatformSpecificBase):
+        pass
+
+
+# load some constants for later use.
+# try to use values from termios, use defaults from linux otherwise
+TIOCMGET = getattr(termios, 'TIOCMGET', 0x5415)
+TIOCMBIS = getattr(termios, 'TIOCMBIS', 0x5416)
+TIOCMBIC = getattr(termios, 'TIOCMBIC', 0x5417)
+TIOCMSET = getattr(termios, 'TIOCMSET', 0x5418)
+
+# TIOCM_LE = getattr(termios, 'TIOCM_LE', 0x001)
+TIOCM_DTR = getattr(termios, 'TIOCM_DTR', 0x002)
+TIOCM_RTS = getattr(termios, 'TIOCM_RTS', 0x004)
+# TIOCM_ST = getattr(termios, 'TIOCM_ST', 0x008)
+# TIOCM_SR = getattr(termios, 'TIOCM_SR', 0x010)
+
+TIOCM_CTS = getattr(termios, 'TIOCM_CTS', 0x020)
+TIOCM_CAR = getattr(termios, 'TIOCM_CAR', 0x040)
+TIOCM_RNG = getattr(termios, 'TIOCM_RNG', 0x080)
+TIOCM_DSR = getattr(termios, 'TIOCM_DSR', 0x100)
+TIOCM_CD = getattr(termios, 'TIOCM_CD', TIOCM_CAR)
+TIOCM_RI = getattr(termios, 'TIOCM_RI', TIOCM_RNG)
+# TIOCM_OUT1 = getattr(termios, 'TIOCM_OUT1', 0x2000)
+# TIOCM_OUT2 = getattr(termios, 'TIOCM_OUT2', 0x4000)
+if hasattr(termios, 'TIOCINQ'):
+    TIOCINQ = termios.TIOCINQ
+else:
+    TIOCINQ = getattr(termios, 'FIONREAD', 0x541B)
+TIOCOUTQ = getattr(termios, 'TIOCOUTQ', 0x5411)
+
+TIOCM_zero_str = struct.pack('I', 0)
+TIOCM_RTS_str = struct.pack('I', TIOCM_RTS)
+TIOCM_DTR_str = struct.pack('I', TIOCM_DTR)
+
+TIOCSBRK = getattr(termios, 'TIOCSBRK', 0x5427)
+TIOCCBRK = getattr(termios, 'TIOCCBRK', 0x5428)
+
+
+class Serial(SerialBase, PlatformSpecific):
+    """\
+    Serial port class POSIX implementation. Serial port configuration is
+    done with termios and fcntl. Runs on Linux and many other Un*x like
+    systems.
+    """
+
+    def open(self):
+        """\
+        Open port with current settings. This may throw a SerialException
+        if the port cannot be opened."""
+        if self._port is None:
+            raise SerialException("Port must be configured before it can be used.")
+        if self.is_open:
+            raise SerialException("Port is already open.")
+        self.fd = None
+        # open
+        try:
+            self.fd = os.open(self.portstr, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
+        except OSError as msg:
+            self.fd = None
+            raise SerialException(msg.errno, "could not open port {}: {}".format(self._port, msg))
+        #~ fcntl.fcntl(self.fd, fcntl.F_SETFL, 0)  # set blocking
+
+        self.pipe_abort_read_r, self.pipe_abort_read_w = None, None
+        self.pipe_abort_write_r, self.pipe_abort_write_w = None, None
+
+        try:
+            self._reconfigure_port(force_update=True)
+
+            try:
+                if not self._dsrdtr:
+                    self._update_dtr_state()
+                if not self._rtscts:
+                    self._update_rts_state()
+            except IOError as e:
+                # ignore Invalid argument and Inappropriate ioctl
+                if e.errno not in (errno.EINVAL, errno.ENOTTY):
+                    raise
+
+            self._reset_input_buffer()
+
+            self.pipe_abort_read_r, self.pipe_abort_read_w = os.pipe()
+            self.pipe_abort_write_r, self.pipe_abort_write_w = os.pipe()
+            fcntl.fcntl(self.pipe_abort_read_r, fcntl.F_SETFL, os.O_NONBLOCK)
+            fcntl.fcntl(self.pipe_abort_write_r, fcntl.F_SETFL, os.O_NONBLOCK)
+        except BaseException:
+            try:
+                os.close(self.fd)
+            except Exception:
+                # ignore any exception when closing the port
+                # also to keep original exception that happened when setting up
+                pass
+            self.fd = None
+
+            if self.pipe_abort_read_w is not None:
+                os.close(self.pipe_abort_read_w)
+                self.pipe_abort_read_w = None
+            if self.pipe_abort_read_r is not None:
+                os.close(self.pipe_abort_read_r)
+                self.pipe_abort_read_r = None
+            if self.pipe_abort_write_w is not None:
+                os.close(self.pipe_abort_write_w)
+                self.pipe_abort_write_w = None
+            if self.pipe_abort_write_r is not None:
+                os.close(self.pipe_abort_write_r)
+                self.pipe_abort_write_r = None
+
+            raise
+
+        self.is_open = True
+
+    def _reconfigure_port(self, force_update=False):
+        """Set communication parameters on opened port."""
+        if self.fd is None:
+            raise SerialException("Can only operate on a valid file descriptor")
+
+        # if exclusive lock is requested, create it before we modify anything else
+        if self._exclusive is not None:
+            if self._exclusive:
+                try:
+                    fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+                except IOError as msg:
+                    raise SerialException(msg.errno, "Could not exclusively lock port {}: {}".format(self._port, msg))
+            else:
+                fcntl.flock(self.fd, fcntl.LOCK_UN)
+
+        custom_baud = None
+
+        vmin = vtime = 0                # timeout is done via select
+        if self._inter_byte_timeout is not None:
+            vmin = 1
+            vtime = int(self._inter_byte_timeout * 10)
+        try:
+            orig_attr = termios.tcgetattr(self.fd)
+            iflag, oflag, cflag, lflag, ispeed, ospeed, cc = orig_attr
+        except termios.error as msg:      # if a port is nonexistent but has a /dev file, it'll fail here
+            raise SerialException("Could not configure port: {}".format(msg))
+        # set up raw mode / no echo / binary
+        cflag |= (termios.CLOCAL | termios.CREAD)
+        lflag &= ~(termios.ICANON | termios.ECHO | termios.ECHOE |
+                   termios.ECHOK | termios.ECHONL |
+                   termios.ISIG | termios.IEXTEN)  # |termios.ECHOPRT
+        for flag in ('ECHOCTL', 'ECHOKE'):  # netbsd workaround for Erk
+            if hasattr(termios, flag):
+                lflag &= ~getattr(termios, flag)
+
+        oflag &= ~(termios.OPOST | termios.ONLCR | termios.OCRNL)
+        iflag &= ~(termios.INLCR | termios.IGNCR | termios.ICRNL | termios.IGNBRK)
+        if hasattr(termios, 'IUCLC'):
+            iflag &= ~termios.IUCLC
+        if hasattr(termios, 'PARMRK'):
+            iflag &= ~termios.PARMRK
+
+        # setup baud rate
+        try:
+            ispeed = ospeed = getattr(termios, 'B{}'.format(self._baudrate))
+        except AttributeError:
+            try:
+                ispeed = ospeed = self.BAUDRATE_CONSTANTS[self._baudrate]
+            except KeyError:
+                #~ raise ValueError('Invalid baud rate: %r' % self._baudrate)
+
+                # See if BOTHER is defined for this platform; if it is, use
+                # this for a speed not defined in the baudrate constants list.
+                try:
+                    ispeed = ospeed = BOTHER
+                except NameError:
+                    # may need custom baud rate, it isn't in our list.
+                    ispeed = ospeed = getattr(termios, 'B38400')
+
+                try:
+                    custom_baud = int(self._baudrate)  # store for later
+                except ValueError:
+                    raise ValueError('Invalid baud rate: {!r}'.format(self._baudrate))
+                else:
+                    if custom_baud < 0:
+                        raise ValueError('Invalid baud rate: {!r}'.format(self._baudrate))
+
+        # setup char len
+        cflag &= ~termios.CSIZE
+        if self._bytesize == 8:
+            cflag |= termios.CS8
+        elif self._bytesize == 7:
+            cflag |= termios.CS7
+        elif self._bytesize == 6:
+            cflag |= termios.CS6
+        elif self._bytesize == 5:
+            cflag |= termios.CS5
+        else:
+            raise ValueError('Invalid char len: {!r}'.format(self._bytesize))
+        # setup stop bits
+        if self._stopbits == serial.STOPBITS_ONE:
+            cflag &= ~(termios.CSTOPB)
+        elif self._stopbits == serial.STOPBITS_ONE_POINT_FIVE:
+            cflag |= (termios.CSTOPB)  # XXX same as TWO.. there is no POSIX support for 1.5
+        elif self._stopbits == serial.STOPBITS_TWO:
+            cflag |= (termios.CSTOPB)
+        else:
+            raise ValueError('Invalid stop bit specification: {!r}'.format(self._stopbits))
+        # setup parity
+        iflag &= ~(termios.INPCK | termios.ISTRIP)
+        if self._parity == serial.PARITY_NONE:
+            cflag &= ~(termios.PARENB | termios.PARODD | CMSPAR)
+        elif self._parity == serial.PARITY_EVEN:
+            cflag &= ~(termios.PARODD | CMSPAR)
+            cflag |= (termios.PARENB)
+        elif self._parity == serial.PARITY_ODD:
+            cflag &= ~CMSPAR
+            cflag |= (termios.PARENB | termios.PARODD)
+        elif self._parity == serial.PARITY_MARK and CMSPAR:
+            cflag |= (termios.PARENB | CMSPAR | termios.PARODD)
+        elif self._parity == serial.PARITY_SPACE and CMSPAR:
+            cflag |= (termios.PARENB | CMSPAR)
+            cflag &= ~(termios.PARODD)
+        else:
+            raise ValueError('Invalid parity: {!r}'.format(self._parity))
+        # setup flow control
+        # xonxoff
+        if hasattr(termios, 'IXANY'):
+            if self._xonxoff:
+                iflag |= (termios.IXON | termios.IXOFF)  # |termios.IXANY)
+            else:
+                iflag &= ~(termios.IXON | termios.IXOFF | termios.IXANY)
+        else:
+            if self._xonxoff:
+                iflag |= (termios.IXON | termios.IXOFF)
+            else:
+                iflag &= ~(termios.IXON | termios.IXOFF)
+        # rtscts
+        if hasattr(termios, 'CRTSCTS'):
+            if self._rtscts:
+                cflag |= (termios.CRTSCTS)
+            else:
+                cflag &= ~(termios.CRTSCTS)
+        elif hasattr(termios, 'CNEW_RTSCTS'):   # try it with alternate constant name
+            if self._rtscts:
+                cflag |= (termios.CNEW_RTSCTS)
+            else:
+                cflag &= ~(termios.CNEW_RTSCTS)
+        # XXX should there be a warning if setting up rtscts (and xonxoff etc) fails??
+
+        # buffer
+        # vmin "minimal number of characters to be read. 0 for non blocking"
+        if vmin < 0 or vmin > 255:
+            raise ValueError('Invalid vmin: {!r}'.format(vmin))
+        cc[termios.VMIN] = vmin
+        # vtime
+        if vtime < 0 or vtime > 255:
+            raise ValueError('Invalid vtime: {!r}'.format(vtime))
+        cc[termios.VTIME] = vtime
+        # activate settings
+        if force_update or [iflag, oflag, cflag, lflag, ispeed, ospeed, cc] != orig_attr:
+            termios.tcsetattr(
+                self.fd,
+                termios.TCSANOW,
+                [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
+
+        # apply custom baud rate, if any
+        if custom_baud is not None:
+            self._set_special_baudrate(custom_baud)
+
+        if self._rs485_mode is not None:
+            self._set_rs485_mode(self._rs485_mode)
+
+    def close(self):
+        """Close port"""
+        if self.is_open:
+            if self.fd is not None:
+                os.close(self.fd)
+                self.fd = None
+                os.close(self.pipe_abort_read_w)
+                os.close(self.pipe_abort_read_r)
+                os.close(self.pipe_abort_write_w)
+                os.close(self.pipe_abort_write_r)
+                self.pipe_abort_read_r, self.pipe_abort_read_w = None, None
+                self.pipe_abort_write_r, self.pipe_abort_write_w = None, None
+            self.is_open = False
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    @property
+    def in_waiting(self):
+        """Return the number of bytes currently in the input buffer."""
+        #~ s = fcntl.ioctl(self.fd, termios.FIONREAD, TIOCM_zero_str)
+        s = fcntl.ioctl(self.fd, TIOCINQ, TIOCM_zero_str)
+        return struct.unpack('I', s)[0]
+
+    # select based implementation, proved to work on many systems
+    def read(self, size=1):
+        """\
+        Read size bytes from the serial port. If a timeout is set it may
+        return less characters as requested. With no timeout it will block
+        until the requested number of bytes is read.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        read = bytearray()
+        timeout = Timeout(self._timeout)
+        while len(read) < size:
+            try:
+                ready, _, _ = select.select([self.fd, self.pipe_abort_read_r], [], [], timeout.time_left())
+                if self.pipe_abort_read_r in ready:
+                    os.read(self.pipe_abort_read_r, 1000)
+                    break
+                # If select was used with a timeout, and the timeout occurs, it
+                # returns with empty lists -> thus abort read operation.
+                # For timeout == 0 (non-blocking operation) also abort when
+                # there is nothing to read.
+                if not ready:
+                    break   # timeout
+                buf = os.read(self.fd, size - len(read))
+            except OSError as e:
+                # this is for Python 3.x where select.error is a subclass of
+                # OSError ignore BlockingIOErrors and EINTR. other errors are shown
+                # https://www.python.org/dev/peps/pep-0475.
+                if e.errno not in (errno.EAGAIN, errno.EALREADY, errno.EWOULDBLOCK, errno.EINPROGRESS, errno.EINTR):
+                    raise SerialException('read failed: {}'.format(e))
+            except select.error as e:
+                # this is for Python 2.x
+                # ignore BlockingIOErrors and EINTR. all errors are shown
+                # see also http://www.python.org/dev/peps/pep-3151/#select
+                if e[0] not in (errno.EAGAIN, errno.EALREADY, errno.EWOULDBLOCK, errno.EINPROGRESS, errno.EINTR):
+                    raise SerialException('read failed: {}'.format(e))
+            else:
+                # read should always return some data as select reported it was
+                # ready to read when we get to this point.
+                if not buf:
+                    # Disconnected devices, at least on Linux, show the
+                    # behavior that they are always ready to read immediately
+                    # but reading returns nothing.
+                    raise SerialException(
+                        'device reports readiness to read but returned no data '
+                        '(device disconnected or multiple access on port?)')
+                read.extend(buf)
+
+            if timeout.expired():
+                break
+        return bytes(read)
+
+    def cancel_read(self):
+        if self.is_open:
+            os.write(self.pipe_abort_read_w, b"x")
+
+    def cancel_write(self):
+        if self.is_open:
+            os.write(self.pipe_abort_write_w, b"x")
+
+    def write(self, data):
+        """Output the given byte string over the serial port."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        d = to_bytes(data)
+        tx_len = length = len(d)
+        timeout = Timeout(self._write_timeout)
+        while tx_len > 0:
+            try:
+                n = os.write(self.fd, d)
+                if timeout.is_non_blocking:
+                    # Zero timeout indicates non-blocking - simply return the
+                    # number of bytes of data actually written
+                    return n
+                elif not timeout.is_infinite:
+                    # when timeout is set, use select to wait for being ready
+                    # with the time left as timeout
+                    if timeout.expired():
+                        raise SerialTimeoutException('Write timeout')
+                    abort, ready, _ = select.select([self.pipe_abort_write_r], [self.fd], [], timeout.time_left())
+                    if abort:
+                        os.read(self.pipe_abort_write_r, 1000)
+                        break
+                    if not ready:
+                        raise SerialTimeoutException('Write timeout')
+                else:
+                    assert timeout.time_left() is None
+                    # wait for write operation
+                    abort, ready, _ = select.select([self.pipe_abort_write_r], [self.fd], [], None)
+                    if abort:
+                        os.read(self.pipe_abort_write_r, 1)
+                        break
+                    if not ready:
+                        raise SerialException('write failed (select)')
+                d = d[n:]
+                tx_len -= n
+            except SerialException:
+                raise
+            except OSError as e:
+                # this is for Python 3.x where select.error is a subclass of
+                # OSError ignore BlockingIOErrors and EINTR. other errors are shown
+                # https://www.python.org/dev/peps/pep-0475.
+                if e.errno not in (errno.EAGAIN, errno.EALREADY, errno.EWOULDBLOCK, errno.EINPROGRESS, errno.EINTR):
+                    raise SerialException('write failed: {}'.format(e))
+            except select.error as e:
+                # this is for Python 2.x
+                # ignore BlockingIOErrors and EINTR. all errors are shown
+                # see also http://www.python.org/dev/peps/pep-3151/#select
+                if e[0] not in (errno.EAGAIN, errno.EALREADY, errno.EWOULDBLOCK, errno.EINPROGRESS, errno.EINTR):
+                    raise SerialException('write failed: {}'.format(e))
+            if not timeout.is_non_blocking and timeout.expired():
+                raise SerialTimeoutException('Write timeout')
+        return length - len(d)
+
+    def flush(self):
+        """\
+        Flush of file like objects. In this case, wait until all data
+        is written.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        termios.tcdrain(self.fd)
+
+    def _reset_input_buffer(self):
+        """Clear input buffer, discarding all that is in the buffer."""
+        termios.tcflush(self.fd, termios.TCIFLUSH)
+
+    def reset_input_buffer(self):
+        """Clear input buffer, discarding all that is in the buffer."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._reset_input_buffer()
+
+    def reset_output_buffer(self):
+        """\
+        Clear output buffer, aborting the current output and discarding all
+        that is in the buffer.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        termios.tcflush(self.fd, termios.TCOFLUSH)
+
+    def send_break(self, duration=0.25):
+        """\
+        Send break condition. Timed, returns to idle state after given
+        duration.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        termios.tcsendbreak(self.fd, int(duration / 0.25))
+
+    def _update_rts_state(self):
+        """Set terminal status line: Request To Send"""
+        if self._rts_state:
+            fcntl.ioctl(self.fd, TIOCMBIS, TIOCM_RTS_str)
+        else:
+            fcntl.ioctl(self.fd, TIOCMBIC, TIOCM_RTS_str)
+
+    def _update_dtr_state(self):
+        """Set terminal status line: Data Terminal Ready"""
+        if self._dtr_state:
+            fcntl.ioctl(self.fd, TIOCMBIS, TIOCM_DTR_str)
+        else:
+            fcntl.ioctl(self.fd, TIOCMBIC, TIOCM_DTR_str)
+
+    @property
+    def cts(self):
+        """Read terminal status line: Clear To Send"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        s = fcntl.ioctl(self.fd, TIOCMGET, TIOCM_zero_str)
+        return struct.unpack('I', s)[0] & TIOCM_CTS != 0
+
+    @property
+    def dsr(self):
+        """Read terminal status line: Data Set Ready"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        s = fcntl.ioctl(self.fd, TIOCMGET, TIOCM_zero_str)
+        return struct.unpack('I', s)[0] & TIOCM_DSR != 0
+
+    @property
+    def ri(self):
+        """Read terminal status line: Ring Indicator"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        s = fcntl.ioctl(self.fd, TIOCMGET, TIOCM_zero_str)
+        return struct.unpack('I', s)[0] & TIOCM_RI != 0
+
+    @property
+    def cd(self):
+        """Read terminal status line: Carrier Detect"""
+        if not self.is_open:
+            raise PortNotOpenError()
+        s = fcntl.ioctl(self.fd, TIOCMGET, TIOCM_zero_str)
+        return struct.unpack('I', s)[0] & TIOCM_CD != 0
+
+    # - - platform specific - - - -
+
+    @property
+    def out_waiting(self):
+        """Return the number of bytes currently in the output buffer."""
+        #~ s = fcntl.ioctl(self.fd, termios.FIONREAD, TIOCM_zero_str)
+        s = fcntl.ioctl(self.fd, TIOCOUTQ, TIOCM_zero_str)
+        return struct.unpack('I', s)[0]
+
+    def fileno(self):
+        """\
+        For easier use of the serial port instance with select.
+        WARNING: this function is not portable to different platforms!
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        return self.fd
+
+    def set_input_flow_control(self, enable=True):
+        """\
+        Manually control flow - when software flow control is enabled.
+        This will send XON (true) or XOFF (false) to the other device.
+        WARNING: this function is not portable to different platforms!
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        if enable:
+            termios.tcflow(self.fd, termios.TCION)
+        else:
+            termios.tcflow(self.fd, termios.TCIOFF)
+
+    def set_output_flow_control(self, enable=True):
+        """\
+        Manually control flow of outgoing data - when hardware or software flow
+        control is enabled.
+        WARNING: this function is not portable to different platforms!
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        if enable:
+            termios.tcflow(self.fd, termios.TCOON)
+        else:
+            termios.tcflow(self.fd, termios.TCOOFF)
+
+    def nonblocking(self):
+        """DEPRECATED - has no use"""
+        import warnings
+        warnings.warn("nonblocking() has no effect, already nonblocking", DeprecationWarning)
+
+
+class PosixPollSerial(Serial):
+    """\
+    Poll based read implementation. Not all systems support poll properly.
+    However this one has better handling of errors, such as a device
+    disconnecting while it's in use (e.g. USB-serial unplugged).
+    """
+
+    def read(self, size=1):
+        """\
+        Read size bytes from the serial port. If a timeout is set it may
+        return less characters as requested. With no timeout it will block
+        until the requested number of bytes is read.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        read = bytearray()
+        timeout = Timeout(self._timeout)
+        poll = select.poll()
+        poll.register(self.fd, select.POLLIN | select.POLLERR | select.POLLHUP | select.POLLNVAL)
+        poll.register(self.pipe_abort_read_r, select.POLLIN | select.POLLERR | select.POLLHUP | select.POLLNVAL)
+        if size > 0:
+            while len(read) < size:
+                # print "\tread(): size",size, "have", len(read)    #debug
+                # wait until device becomes ready to read (or something fails)
+                for fd, event in poll.poll(None if timeout.is_infinite else (timeout.time_left() * 1000)):
+                    if fd == self.pipe_abort_read_r:
+                        break
+                    if event & (select.POLLERR | select.POLLHUP | select.POLLNVAL):
+                        raise SerialException('device reports error (poll)')
+                    #  we don't care if it is select.POLLIN or timeout, that's
+                    #  handled below
+                if fd == self.pipe_abort_read_r:
+                    os.read(self.pipe_abort_read_r, 1000)
+                    break
+                buf = os.read(self.fd, size - len(read))
+                read.extend(buf)
+                if timeout.expired() \
+                        or (self._inter_byte_timeout is not None and self._inter_byte_timeout > 0) and not buf:
+                    break   # early abort on timeout
+        return bytes(read)
+
+
+class VTIMESerial(Serial):
+    """\
+    Implement timeout using vtime of tty device instead of using select.
+    This means that no inter character timeout can be specified and that
+    the error handling is degraded.
+
+    Overall timeout is disabled when inter-character timeout is used.
+
+    Note that this implementation does NOT support cancel_read(), it will
+    just ignore that.
+    """
+
+    def _reconfigure_port(self, force_update=True):
+        """Set communication parameters on opened port."""
+        super(VTIMESerial, self)._reconfigure_port()
+        fcntl.fcntl(self.fd, fcntl.F_SETFL, 0)  # clear O_NONBLOCK
+
+        if self._inter_byte_timeout is not None:
+            vmin = 1
+            vtime = int(self._inter_byte_timeout * 10)
+        elif self._timeout is None:
+            vmin = 1
+            vtime = 0
+        else:
+            vmin = 0
+            vtime = int(self._timeout * 10)
+        try:
+            orig_attr = termios.tcgetattr(self.fd)
+            iflag, oflag, cflag, lflag, ispeed, ospeed, cc = orig_attr
+        except termios.error as msg:      # if a port is nonexistent but has a /dev file, it'll fail here
+            raise serial.SerialException("Could not configure port: {}".format(msg))
+
+        if vtime < 0 or vtime > 255:
+            raise ValueError('Invalid vtime: {!r}'.format(vtime))
+        cc[termios.VTIME] = vtime
+        cc[termios.VMIN] = vmin
+
+        termios.tcsetattr(
+                self.fd,
+                termios.TCSANOW,
+                [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
+
+    def read(self, size=1):
+        """\
+        Read size bytes from the serial port. If a timeout is set it may
+        return less characters as requested. With no timeout it will block
+        until the requested number of bytes is read.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        read = bytearray()
+        while len(read) < size:
+            buf = os.read(self.fd, size - len(read))
+            if not buf:
+                break
+            read.extend(buf)
+        return bytes(read)
+
+    # hack to make hasattr return false
+    cancel_read = property()
diff --git a/modbus/serial/serialutil.py b/modbus/serial/serialutil.py
new file mode 100644
index 00000000..789219e9
--- /dev/null
+++ b/modbus/serial/serialutil.py
@@ -0,0 +1,697 @@
+#! python
+#
+# Base class and support functions used by various backends.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2001-2020 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+import io
+import time
+
+# ``memoryview`` was introduced in Python 2.7 and ``bytes(some_memoryview)``
+# isn't returning the contents (very unfortunate). Therefore we need special
+# cases and test for it. Ensure that there is a ``memoryview`` object for older
+# Python versions. This is easier than making every test dependent on its
+# existence.
+try:
+    memoryview
+except (NameError, AttributeError):
+    # implementation does not matter as we do not really use it.
+    # it just must not inherit from something else we might care for.
+    class memoryview(object):   # pylint: disable=redefined-builtin,invalid-name
+        pass
+
+try:
+    unicode
+except (NameError, AttributeError):
+    unicode = str       # for Python 3, pylint: disable=redefined-builtin,invalid-name
+
+try:
+    basestring
+except (NameError, AttributeError):
+    basestring = (str,)    # for Python 3, pylint: disable=redefined-builtin,invalid-name
+
+
+# "for byte in data" fails for python3 as it returns ints instead of bytes
+def iterbytes(b):
+    """Iterate over bytes, returning bytes instead of ints (python3)"""
+    if isinstance(b, memoryview):
+        b = b.tobytes()
+    i = 0
+    while True:
+        a = b[i:i + 1]
+        i += 1
+        if a:
+            yield a
+        else:
+            break
+
+
+# all Python versions prior 3.x convert ``str([17])`` to '[17]' instead of '\x11'
+# so a simple ``bytes(sequence)`` doesn't work for all versions
+def to_bytes(seq):
+    """convert a sequence to a bytes type"""
+    if isinstance(seq, bytes):
+        return seq
+    elif isinstance(seq, bytearray):
+        return bytes(seq)
+    elif isinstance(seq, memoryview):
+        return seq.tobytes()
+    elif isinstance(seq, unicode):
+        raise TypeError('unicode strings are not supported, please encode to bytes: {!r}'.format(seq))
+    else:
+        # handle list of integers and bytes (one or more items) for Python 2 and 3
+        return bytes(bytearray(seq))
+
+
+# create control bytes
+XON = to_bytes([17])
+XOFF = to_bytes([19])
+
+CR = to_bytes([13])
+LF = to_bytes([10])
+
+
+PARITY_NONE, PARITY_EVEN, PARITY_ODD, PARITY_MARK, PARITY_SPACE = 'N', 'E', 'O', 'M', 'S'
+STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO = (1, 1.5, 2)
+FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS = (5, 6, 7, 8)
+
+PARITY_NAMES = {
+    PARITY_NONE: 'None',
+    PARITY_EVEN: 'Even',
+    PARITY_ODD: 'Odd',
+    PARITY_MARK: 'Mark',
+    PARITY_SPACE: 'Space',
+}
+
+
+class SerialException(IOError):
+    """Base class for serial port related exceptions."""
+
+
+class SerialTimeoutException(SerialException):
+    """Write timeouts give an exception"""
+
+
+class PortNotOpenError(SerialException):
+    """Port is not open"""
+    def __init__(self):
+        super(PortNotOpenError, self).__init__('Attempting to use a port that is not open')
+
+
+class Timeout(object):
+    """\
+    Abstraction for timeout operations. Using time.monotonic() if available
+    or time.time() in all other cases.
+
+    The class can also be initialized with 0 or None, in order to support
+    non-blocking and fully blocking I/O operations. The attributes
+    is_non_blocking and is_infinite are set accordingly.
+    """
+    if hasattr(time, 'monotonic'):
+        # Timeout implementation with time.monotonic(). This function is only
+        # supported by Python 3.3 and above. It returns a time in seconds
+        # (float) just as time.time(), but is not affected by system clock
+        # adjustments.
+        TIME = time.monotonic
+    else:
+        # Timeout implementation with time.time(). This is compatible with all
+        # Python versions but has issues if the clock is adjusted while the
+        # timeout is running.
+        TIME = time.time
+
+    def __init__(self, duration):
+        """Initialize a timeout with given duration"""
+        self.is_infinite = (duration is None)
+        self.is_non_blocking = (duration == 0)
+        self.duration = duration
+        if duration is not None:
+            self.target_time = self.TIME() + duration
+        else:
+            self.target_time = None
+
+    def expired(self):
+        """Return a boolean, telling if the timeout has expired"""
+        return self.target_time is not None and self.time_left() <= 0
+
+    def time_left(self):
+        """Return how many seconds are left until the timeout expires"""
+        if self.is_non_blocking:
+            return 0
+        elif self.is_infinite:
+            return None
+        else:
+            delta = self.target_time - self.TIME()
+            if delta > self.duration:
+                # clock jumped, recalculate
+                self.target_time = self.TIME() + self.duration
+                return self.duration
+            else:
+                return max(0, delta)
+
+    def restart(self, duration):
+        """\
+        Restart a timeout, only supported if a timeout was already set up
+        before.
+        """
+        self.duration = duration
+        self.target_time = self.TIME() + duration
+
+
+class SerialBase(io.RawIOBase):
+    """\
+    Serial port base class. Provides __init__ function and properties to
+    get/set port settings.
+    """
+
+    # default values, may be overridden in subclasses that do not support all values
+    BAUDRATES = (50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800,
+                 9600, 19200, 38400, 57600, 115200, 230400, 460800, 500000,
+                 576000, 921600, 1000000, 1152000, 1500000, 2000000, 2500000,
+                 3000000, 3500000, 4000000)
+    BYTESIZES = (FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS)
+    PARITIES = (PARITY_NONE, PARITY_EVEN, PARITY_ODD, PARITY_MARK, PARITY_SPACE)
+    STOPBITS = (STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO)
+
+    def __init__(self,
+                 port=None,
+                 baudrate=9600,
+                 bytesize=EIGHTBITS,
+                 parity=PARITY_NONE,
+                 stopbits=STOPBITS_ONE,
+                 timeout=None,
+                 xonxoff=False,
+                 rtscts=False,
+                 write_timeout=None,
+                 dsrdtr=False,
+                 inter_byte_timeout=None,
+                 exclusive=None,
+                 **kwargs):
+        """\
+        Initialize comm port object. If a "port" is given, then the port will be
+        opened immediately. Otherwise a Serial port object in closed state
+        is returned.
+        """
+
+        self.is_open = False
+        self.portstr = None
+        self.name = None
+        # correct values are assigned below through properties
+        self._port = None
+        self._baudrate = None
+        self._bytesize = None
+        self._parity = None
+        self._stopbits = None
+        self._timeout = None
+        self._write_timeout = None
+        self._xonxoff = None
+        self._rtscts = None
+        self._dsrdtr = None
+        self._inter_byte_timeout = None
+        self._rs485_mode = None  # disabled by default
+        self._rts_state = True
+        self._dtr_state = True
+        self._break_state = False
+        self._exclusive = None
+
+        # assign values using get/set methods using the properties feature
+        self.port = port
+        self.baudrate = baudrate
+        self.bytesize = bytesize
+        self.parity = parity
+        self.stopbits = stopbits
+        self.timeout = timeout
+        self.write_timeout = write_timeout
+        self.xonxoff = xonxoff
+        self.rtscts = rtscts
+        self.dsrdtr = dsrdtr
+        self.inter_byte_timeout = inter_byte_timeout
+        self.exclusive = exclusive
+
+        # watch for backward compatible kwargs
+        if 'writeTimeout' in kwargs:
+            self.write_timeout = kwargs.pop('writeTimeout')
+        if 'interCharTimeout' in kwargs:
+            self.inter_byte_timeout = kwargs.pop('interCharTimeout')
+        if kwargs:
+            raise ValueError('unexpected keyword arguments: {!r}'.format(kwargs))
+
+        if port is not None:
+            self.open()
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    # to be implemented by subclasses:
+    # def open(self):
+    # def close(self):
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    @property
+    def port(self):
+        """\
+        Get the current port setting. The value that was passed on init or using
+        setPort() is passed back.
+        """
+        return self._port
+
+    @port.setter
+    def port(self, port):
+        """\
+        Change the port.
+        """
+        if port is not None and not isinstance(port, basestring):
+            raise ValueError('"port" must be None or a string, not {}'.format(type(port)))
+        was_open = self.is_open
+        if was_open:
+            self.close()
+        self.portstr = port
+        self._port = port
+        self.name = self.portstr
+        if was_open:
+            self.open()
+
+    @property
+    def baudrate(self):
+        """Get the current baud rate setting."""
+        return self._baudrate
+
+    @baudrate.setter
+    def baudrate(self, baudrate):
+        """\
+        Change baud rate. It raises a ValueError if the port is open and the
+        baud rate is not possible. If the port is closed, then the value is
+        accepted and the exception is raised when the port is opened.
+        """
+        try:
+            b = int(baudrate)
+        except TypeError:
+            raise ValueError("Not a valid baudrate: {!r}".format(baudrate))
+        else:
+            if b < 0:
+                raise ValueError("Not a valid baudrate: {!r}".format(baudrate))
+            self._baudrate = b
+            if self.is_open:
+                self._reconfigure_port()
+
+    @property
+    def bytesize(self):
+        """Get the current byte size setting."""
+        return self._bytesize
+
+    @bytesize.setter
+    def bytesize(self, bytesize):
+        """Change byte size."""
+        if bytesize not in self.BYTESIZES:
+            raise ValueError("Not a valid byte size: {!r}".format(bytesize))
+        self._bytesize = bytesize
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def exclusive(self):
+        """Get the current exclusive access setting."""
+        return self._exclusive
+
+    @exclusive.setter
+    def exclusive(self, exclusive):
+        """Change the exclusive access setting."""
+        self._exclusive = exclusive
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def parity(self):
+        """Get the current parity setting."""
+        return self._parity
+
+    @parity.setter
+    def parity(self, parity):
+        """Change parity setting."""
+        if parity not in self.PARITIES:
+            raise ValueError("Not a valid parity: {!r}".format(parity))
+        self._parity = parity
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def stopbits(self):
+        """Get the current stop bits setting."""
+        return self._stopbits
+
+    @stopbits.setter
+    def stopbits(self, stopbits):
+        """Change stop bits size."""
+        if stopbits not in self.STOPBITS:
+            raise ValueError("Not a valid stop bit size: {!r}".format(stopbits))
+        self._stopbits = stopbits
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def timeout(self):
+        """Get the current timeout setting."""
+        return self._timeout
+
+    @timeout.setter
+    def timeout(self, timeout):
+        """Change timeout setting."""
+        if timeout is not None:
+            try:
+                timeout + 1     # test if it's a number, will throw a TypeError if not...
+            except TypeError:
+                raise ValueError("Not a valid timeout: {!r}".format(timeout))
+            if timeout < 0:
+                raise ValueError("Not a valid timeout: {!r}".format(timeout))
+        self._timeout = timeout
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def write_timeout(self):
+        """Get the current timeout setting."""
+        return self._write_timeout
+
+    @write_timeout.setter
+    def write_timeout(self, timeout):
+        """Change timeout setting."""
+        if timeout is not None:
+            if timeout < 0:
+                raise ValueError("Not a valid timeout: {!r}".format(timeout))
+            try:
+                timeout + 1     # test if it's a number, will throw a TypeError if not...
+            except TypeError:
+                raise ValueError("Not a valid timeout: {!r}".format(timeout))
+
+        self._write_timeout = timeout
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def inter_byte_timeout(self):
+        """Get the current inter-character timeout setting."""
+        return self._inter_byte_timeout
+
+    @inter_byte_timeout.setter
+    def inter_byte_timeout(self, ic_timeout):
+        """Change inter-byte timeout setting."""
+        if ic_timeout is not None:
+            if ic_timeout < 0:
+                raise ValueError("Not a valid timeout: {!r}".format(ic_timeout))
+            try:
+                ic_timeout + 1     # test if it's a number, will throw a TypeError if not...
+            except TypeError:
+                raise ValueError("Not a valid timeout: {!r}".format(ic_timeout))
+
+        self._inter_byte_timeout = ic_timeout
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def xonxoff(self):
+        """Get the current XON/XOFF setting."""
+        return self._xonxoff
+
+    @xonxoff.setter
+    def xonxoff(self, xonxoff):
+        """Change XON/XOFF setting."""
+        self._xonxoff = xonxoff
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def rtscts(self):
+        """Get the current RTS/CTS flow control setting."""
+        return self._rtscts
+
+    @rtscts.setter
+    def rtscts(self, rtscts):
+        """Change RTS/CTS flow control setting."""
+        self._rtscts = rtscts
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def dsrdtr(self):
+        """Get the current DSR/DTR flow control setting."""
+        return self._dsrdtr
+
+    @dsrdtr.setter
+    def dsrdtr(self, dsrdtr=None):
+        """Change DsrDtr flow control setting."""
+        if dsrdtr is None:
+            # if not set, keep backwards compatibility and follow rtscts setting
+            self._dsrdtr = self._rtscts
+        else:
+            # if defined independently, follow its value
+            self._dsrdtr = dsrdtr
+        if self.is_open:
+            self._reconfigure_port()
+
+    @property
+    def rts(self):
+        return self._rts_state
+
+    @rts.setter
+    def rts(self, value):
+        self._rts_state = value
+        if self.is_open:
+            self._update_rts_state()
+
+    @property
+    def dtr(self):
+        return self._dtr_state
+
+    @dtr.setter
+    def dtr(self, value):
+        self._dtr_state = value
+        if self.is_open:
+            self._update_dtr_state()
+
+    @property
+    def break_condition(self):
+        return self._break_state
+
+    @break_condition.setter
+    def break_condition(self, value):
+        self._break_state = value
+        if self.is_open:
+            self._update_break_state()
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+    # functions useful for RS-485 adapters
+
+    @property
+    def rs485_mode(self):
+        """\
+        Enable RS485 mode and apply new settings, set to None to disable.
+        See serial.rs485.RS485Settings for more info about the value.
+        """
+        return self._rs485_mode
+
+    @rs485_mode.setter
+    def rs485_mode(self, rs485_settings):
+        self._rs485_mode = rs485_settings
+        if self.is_open:
+            self._reconfigure_port()
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    _SAVED_SETTINGS = ('baudrate', 'bytesize', 'parity', 'stopbits', 'xonxoff',
+                       'dsrdtr', 'rtscts', 'timeout', 'write_timeout',
+                       'inter_byte_timeout')
+
+    def get_settings(self):
+        """\
+        Get current port settings as a dictionary. For use with
+        apply_settings().
+        """
+        return dict([(key, getattr(self, '_' + key)) for key in self._SAVED_SETTINGS])
+
+    def apply_settings(self, d):
+        """\
+        Apply stored settings from a dictionary returned from
+        get_settings(). It's allowed to delete keys from the dictionary. These
+        values will simply left unchanged.
+        """
+        for key in self._SAVED_SETTINGS:
+            if key in d and d[key] != getattr(self, '_' + key):   # check against internal "_" value
+                setattr(self, key, d[key])          # set non "_" value to use properties write function
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    def __repr__(self):
+        """String representation of the current port settings and its state."""
+        return '{name}(port={p.portstr!r}, ' \
+               'baudrate={p.baudrate!r}, bytesize={p.bytesize!r}, parity={p.parity!r}, ' \
+               'stopbits={p.stopbits!r}, timeout={p.timeout!r}, xonxoff={p.xonxoff!r}, ' \
+               'rtscts={p.rtscts!r}, dsrdtr={p.dsrdtr!r})'.format(
+                   name=self.__class__.__name__, id=id(self), p=self)
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+    # compatibility with io library
+    # pylint: disable=invalid-name,missing-docstring
+
+    def readable(self):
+        return True
+
+    def writable(self):
+        return True
+
+    def seekable(self):
+        return False
+
+    def readinto(self, b):
+        data = self.read(len(b))
+        n = len(data)
+        try:
+            b[:n] = data
+        except TypeError as err:
+            import array
+            if not isinstance(b, array.array):
+                raise err
+            b[:n] = array.array('b', data)
+        return n
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+    # context manager
+
+    def __enter__(self):
+        if self._port is not None and not self.is_open:
+            self.open()
+        return self
+
+    def __exit__(self, *args, **kwargs):
+        self.close()
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    def send_break(self, duration=0.25):
+        """\
+        Send break condition. Timed, returns to idle state after given
+        duration.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        self.break_condition = True
+        time.sleep(duration)
+        self.break_condition = False
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+    # backwards compatibility / deprecated functions
+
+    def flushInput(self):
+        self.reset_input_buffer()
+
+    def flushOutput(self):
+        self.reset_output_buffer()
+
+    def inWaiting(self):
+        return self.in_waiting
+
+    def sendBreak(self, duration=0.25):
+        self.send_break(duration)
+
+    def setRTS(self, value=1):
+        self.rts = value
+
+    def setDTR(self, value=1):
+        self.dtr = value
+
+    def getCTS(self):
+        return self.cts
+
+    def getDSR(self):
+        return self.dsr
+
+    def getRI(self):
+        return self.ri
+
+    def getCD(self):
+        return self.cd
+
+    def setPort(self, port):
+        self.port = port
+
+    @property
+    def writeTimeout(self):
+        return self.write_timeout
+
+    @writeTimeout.setter
+    def writeTimeout(self, timeout):
+        self.write_timeout = timeout
+
+    @property
+    def interCharTimeout(self):
+        return self.inter_byte_timeout
+
+    @interCharTimeout.setter
+    def interCharTimeout(self, interCharTimeout):
+        self.inter_byte_timeout = interCharTimeout
+
+    def getSettingsDict(self):
+        return self.get_settings()
+
+    def applySettingsDict(self, d):
+        self.apply_settings(d)
+
+    def isOpen(self):
+        return self.is_open
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+    # additional functionality
+
+    def read_all(self):
+        """\
+        Read all bytes currently available in the buffer of the OS.
+        """
+        return self.read(self.in_waiting)
+
+    def read_until(self, expected=LF, size=None):
+        """\
+        Read until an expected sequence is found ('\n' by default), the size
+        is exceeded or until timeout occurs.
+        """
+        lenterm = len(expected)
+        line = bytearray()
+        timeout = Timeout(self._timeout)
+        while True:
+            c = self.read(1)
+            if c:
+                line += c
+                if line[-lenterm:] == expected:
+                    break
+                if size is not None and len(line) >= size:
+                    break
+            else:
+                break
+            if timeout.expired():
+                break
+        return bytes(line)
+
+    def iread_until(self, *args, **kwargs):
+        """\
+        Read lines, implemented as generator. It will raise StopIteration on
+        timeout (empty read).
+        """
+        while True:
+            line = self.read_until(*args, **kwargs)
+            if not line:
+                break
+            yield line
+
+
+#  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+if __name__ == '__main__':
+    import sys
+    s = SerialBase()
+    sys.stdout.write('port name:  {}\n'.format(s.name))
+    sys.stdout.write('baud rates: {}\n'.format(s.BAUDRATES))
+    sys.stdout.write('byte sizes: {}\n'.format(s.BYTESIZES))
+    sys.stdout.write('parities:   {}\n'.format(s.PARITIES))
+    sys.stdout.write('stop bits:  {}\n'.format(s.STOPBITS))
+    sys.stdout.write('{}\n'.format(s))
diff --git a/modbus/serial/serialwin32.py b/modbus/serial/serialwin32.py
new file mode 100644
index 00000000..e7da929a
--- /dev/null
+++ b/modbus/serial/serialwin32.py
@@ -0,0 +1,477 @@
+#! python
+#
+# backend for Windows ("win32" incl. 32/64 bit support)
+#
+# (C) 2001-2020 Chris Liechti 
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# SPDX-License-Identifier:    BSD-3-Clause
+#
+# Initial patch to use ctypes by Giovanni Bajo 
+
+from __future__ import absolute_import
+
+# pylint: disable=invalid-name,too-few-public-methods
+import ctypes
+import time
+from serial import win32
+
+import serial
+from serial.serialutil import SerialBase, SerialException, to_bytes, PortNotOpenError, SerialTimeoutException
+
+
+class Serial(SerialBase):
+    """Serial port implementation for Win32 based on ctypes."""
+
+    BAUDRATES = (50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800,
+                 9600, 19200, 38400, 57600, 115200)
+
+    def __init__(self, *args, **kwargs):
+        self._port_handle = None
+        self._overlapped_read = None
+        self._overlapped_write = None
+        super(Serial, self).__init__(*args, **kwargs)
+
+    def open(self):
+        """\
+        Open port with current settings. This may throw a SerialException
+        if the port cannot be opened.
+        """
+        if self._port is None:
+            raise SerialException("Port must be configured before it can be used.")
+        if self.is_open:
+            raise SerialException("Port is already open.")
+        # the "\\.\COMx" format is required for devices other than COM1-COM8
+        # not all versions of windows seem to support this properly
+        # so that the first few ports are used with the DOS device name
+        port = self.name
+        try:
+            if port.upper().startswith('COM') and int(port[3:]) > 8:
+                port = '\\\\.\\' + port
+        except ValueError:
+            # for like COMnotanumber
+            pass
+        self._port_handle = win32.CreateFile(
+            port,
+            win32.GENERIC_READ | win32.GENERIC_WRITE,
+            0,  # exclusive access
+            None,  # no security
+            win32.OPEN_EXISTING,
+            win32.FILE_ATTRIBUTE_NORMAL | win32.FILE_FLAG_OVERLAPPED,
+            0)
+        if self._port_handle == win32.INVALID_HANDLE_VALUE:
+            self._port_handle = None    # 'cause __del__ is called anyway
+            raise SerialException("could not open port {!r}: {!r}".format(self.portstr, ctypes.WinError()))
+
+        try:
+            self._overlapped_read = win32.OVERLAPPED()
+            self._overlapped_read.hEvent = win32.CreateEvent(None, 1, 0, None)
+            self._overlapped_write = win32.OVERLAPPED()
+            #~ self._overlapped_write.hEvent = win32.CreateEvent(None, 1, 0, None)
+            self._overlapped_write.hEvent = win32.CreateEvent(None, 0, 0, None)
+
+            # Setup a 4k buffer
+            win32.SetupComm(self._port_handle, 4096, 4096)
+
+            # Save original timeout values:
+            self._orgTimeouts = win32.COMMTIMEOUTS()
+            win32.GetCommTimeouts(self._port_handle, ctypes.byref(self._orgTimeouts))
+
+            self._reconfigure_port()
+
+            # Clear buffers:
+            # Remove anything that was there
+            win32.PurgeComm(
+                self._port_handle,
+                win32.PURGE_TXCLEAR | win32.PURGE_TXABORT |
+                win32.PURGE_RXCLEAR | win32.PURGE_RXABORT)
+        except:
+            try:
+                self._close()
+            except:
+                # ignore any exception when closing the port
+                # also to keep original exception that happened when setting up
+                pass
+            self._port_handle = None
+            raise
+        else:
+            self.is_open = True
+
+    def _reconfigure_port(self):
+        """Set communication parameters on opened port."""
+        if not self._port_handle:
+            raise SerialException("Can only operate on a valid port handle")
+
+        # Set Windows timeout values
+        # timeouts is a tuple with the following items:
+        # (ReadIntervalTimeout,ReadTotalTimeoutMultiplier,
+        #  ReadTotalTimeoutConstant,WriteTotalTimeoutMultiplier,
+        #  WriteTotalTimeoutConstant)
+        timeouts = win32.COMMTIMEOUTS()
+        if self._timeout is None:
+            pass  # default of all zeros is OK
+        elif self._timeout == 0:
+            timeouts.ReadIntervalTimeout = win32.MAXDWORD
+        else:
+            timeouts.ReadTotalTimeoutConstant = max(int(self._timeout * 1000), 1)
+        if self._timeout != 0 and self._inter_byte_timeout is not None:
+            timeouts.ReadIntervalTimeout = max(int(self._inter_byte_timeout * 1000), 1)
+
+        if self._write_timeout is None:
+            pass
+        elif self._write_timeout == 0:
+            timeouts.WriteTotalTimeoutConstant = win32.MAXDWORD
+        else:
+            timeouts.WriteTotalTimeoutConstant = max(int(self._write_timeout * 1000), 1)
+        win32.SetCommTimeouts(self._port_handle, ctypes.byref(timeouts))
+
+        win32.SetCommMask(self._port_handle, win32.EV_ERR)
+
+        # Setup the connection info.
+        # Get state and modify it:
+        comDCB = win32.DCB()
+        win32.GetCommState(self._port_handle, ctypes.byref(comDCB))
+        comDCB.BaudRate = self._baudrate
+
+        if self._bytesize == serial.FIVEBITS:
+            comDCB.ByteSize = 5
+        elif self._bytesize == serial.SIXBITS:
+            comDCB.ByteSize = 6
+        elif self._bytesize == serial.SEVENBITS:
+            comDCB.ByteSize = 7
+        elif self._bytesize == serial.EIGHTBITS:
+            comDCB.ByteSize = 8
+        else:
+            raise ValueError("Unsupported number of data bits: {!r}".format(self._bytesize))
+
+        if self._parity == serial.PARITY_NONE:
+            comDCB.Parity = win32.NOPARITY
+            comDCB.fParity = 0  # Disable Parity Check
+        elif self._parity == serial.PARITY_EVEN:
+            comDCB.Parity = win32.EVENPARITY
+            comDCB.fParity = 1  # Enable Parity Check
+        elif self._parity == serial.PARITY_ODD:
+            comDCB.Parity = win32.ODDPARITY
+            comDCB.fParity = 1  # Enable Parity Check
+        elif self._parity == serial.PARITY_MARK:
+            comDCB.Parity = win32.MARKPARITY
+            comDCB.fParity = 1  # Enable Parity Check
+        elif self._parity == serial.PARITY_SPACE:
+            comDCB.Parity = win32.SPACEPARITY
+            comDCB.fParity = 1  # Enable Parity Check
+        else:
+            raise ValueError("Unsupported parity mode: {!r}".format(self._parity))
+
+        if self._stopbits == serial.STOPBITS_ONE:
+            comDCB.StopBits = win32.ONESTOPBIT
+        elif self._stopbits == serial.STOPBITS_ONE_POINT_FIVE:
+            comDCB.StopBits = win32.ONE5STOPBITS
+        elif self._stopbits == serial.STOPBITS_TWO:
+            comDCB.StopBits = win32.TWOSTOPBITS
+        else:
+            raise ValueError("Unsupported number of stop bits: {!r}".format(self._stopbits))
+
+        comDCB.fBinary = 1  # Enable Binary Transmission
+        # Char. w/ Parity-Err are replaced with 0xff (if fErrorChar is set to TRUE)
+        if self._rs485_mode is None:
+            if self._rtscts:
+                comDCB.fRtsControl = win32.RTS_CONTROL_HANDSHAKE
+            else:
+                comDCB.fRtsControl = win32.RTS_CONTROL_ENABLE if self._rts_state else win32.RTS_CONTROL_DISABLE
+            comDCB.fOutxCtsFlow = self._rtscts
+        else:
+            # checks for unsupported settings
+            # XXX verify if platform really does not have a setting for those
+            if not self._rs485_mode.rts_level_for_tx:
+                raise ValueError(
+                    'Unsupported value for RS485Settings.rts_level_for_tx: {!r} (only True is allowed)'.format(
+                        self._rs485_mode.rts_level_for_tx,))
+            if self._rs485_mode.rts_level_for_rx:
+                raise ValueError(
+                    'Unsupported value for RS485Settings.rts_level_for_rx: {!r} (only False is allowed)'.format(
+                        self._rs485_mode.rts_level_for_rx,))
+            if self._rs485_mode.delay_before_tx is not None:
+                raise ValueError(
+                    'Unsupported value for RS485Settings.delay_before_tx: {!r} (only None is allowed)'.format(
+                        self._rs485_mode.delay_before_tx,))
+            if self._rs485_mode.delay_before_rx is not None:
+                raise ValueError(
+                    'Unsupported value for RS485Settings.delay_before_rx: {!r} (only None is allowed)'.format(
+                        self._rs485_mode.delay_before_rx,))
+            if self._rs485_mode.loopback:
+                raise ValueError(
+                    'Unsupported value for RS485Settings.loopback: {!r} (only False is allowed)'.format(
+                        self._rs485_mode.loopback,))
+            comDCB.fRtsControl = win32.RTS_CONTROL_TOGGLE
+            comDCB.fOutxCtsFlow = 0
+
+        if self._dsrdtr:
+            comDCB.fDtrControl = win32.DTR_CONTROL_HANDSHAKE
+        else:
+            comDCB.fDtrControl = win32.DTR_CONTROL_ENABLE if self._dtr_state else win32.DTR_CONTROL_DISABLE
+        comDCB.fOutxDsrFlow = self._dsrdtr
+        comDCB.fOutX = self._xonxoff
+        comDCB.fInX = self._xonxoff
+        comDCB.fNull = 0
+        comDCB.fErrorChar = 0
+        comDCB.fAbortOnError = 0
+        comDCB.XonChar = serial.XON
+        comDCB.XoffChar = serial.XOFF
+
+        if not win32.SetCommState(self._port_handle, ctypes.byref(comDCB)):
+            raise SerialException(
+                'Cannot configure port, something went wrong. '
+                'Original message: {!r}'.format(ctypes.WinError()))
+
+    #~ def __del__(self):
+        #~ self.close()
+
+    def _close(self):
+        """internal close port helper"""
+        if self._port_handle is not None:
+            # Restore original timeout values:
+            win32.SetCommTimeouts(self._port_handle, self._orgTimeouts)
+            if self._overlapped_read is not None:
+                self.cancel_read()
+                win32.CloseHandle(self._overlapped_read.hEvent)
+                self._overlapped_read = None
+            if self._overlapped_write is not None:
+                self.cancel_write()
+                win32.CloseHandle(self._overlapped_write.hEvent)
+                self._overlapped_write = None
+            win32.CloseHandle(self._port_handle)
+            self._port_handle = None
+
+    def close(self):
+        """Close port"""
+        if self.is_open:
+            self._close()
+            self.is_open = False
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+
+    @property
+    def in_waiting(self):
+        """Return the number of bytes currently in the input buffer."""
+        flags = win32.DWORD()
+        comstat = win32.COMSTAT()
+        if not win32.ClearCommError(self._port_handle, ctypes.byref(flags), ctypes.byref(comstat)):
+            raise SerialException("ClearCommError failed ({!r})".format(ctypes.WinError()))
+        return comstat.cbInQue
+
+    def read(self, size=1):
+        """\
+        Read size bytes from the serial port. If a timeout is set it may
+        return less characters as requested. With no timeout it will block
+        until the requested number of bytes is read.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        if size > 0:
+            win32.ResetEvent(self._overlapped_read.hEvent)
+            flags = win32.DWORD()
+            comstat = win32.COMSTAT()
+            if not win32.ClearCommError(self._port_handle, ctypes.byref(flags), ctypes.byref(comstat)):
+                raise SerialException("ClearCommError failed ({!r})".format(ctypes.WinError()))
+            n = min(comstat.cbInQue, size) if self.timeout == 0 else size
+            if n > 0:
+                buf = ctypes.create_string_buffer(n)
+                rc = win32.DWORD()
+                read_ok = win32.ReadFile(
+                    self._port_handle,
+                    buf,
+                    n,
+                    ctypes.byref(rc),
+                    ctypes.byref(self._overlapped_read))
+                if not read_ok and win32.GetLastError() not in (win32.ERROR_SUCCESS, win32.ERROR_IO_PENDING):
+                    raise SerialException("ReadFile failed ({!r})".format(ctypes.WinError()))
+                result_ok = win32.GetOverlappedResult(
+                    self._port_handle,
+                    ctypes.byref(self._overlapped_read),
+                    ctypes.byref(rc),
+                    True)
+                if not result_ok:
+                    if win32.GetLastError() != win32.ERROR_OPERATION_ABORTED:
+                        raise SerialException("GetOverlappedResult failed ({!r})".format(ctypes.WinError()))
+                read = buf.raw[:rc.value]
+            else:
+                read = bytes()
+        else:
+            read = bytes()
+        return bytes(read)
+
+    def write(self, data):
+        """Output the given byte string over the serial port."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        #~ if not isinstance(data, (bytes, bytearray)):
+            #~ raise TypeError('expected %s or bytearray, got %s' % (bytes, type(data)))
+        # convert data (needed in case of memoryview instance: Py 3.1 io lib), ctypes doesn't like memoryview
+        data = to_bytes(data)
+        if data:
+            #~ win32event.ResetEvent(self._overlapped_write.hEvent)
+            n = win32.DWORD()
+            success = win32.WriteFile(self._port_handle, data, len(data), ctypes.byref(n), self._overlapped_write)
+            if self._write_timeout != 0:  # if blocking (None) or w/ write timeout (>0)
+                if not success and win32.GetLastError() not in (win32.ERROR_SUCCESS, win32.ERROR_IO_PENDING):
+                    raise SerialException("WriteFile failed ({!r})".format(ctypes.WinError()))
+
+                # Wait for the write to complete.
+                #~ win32.WaitForSingleObject(self._overlapped_write.hEvent, win32.INFINITE)
+                win32.GetOverlappedResult(self._port_handle, self._overlapped_write, ctypes.byref(n), True)
+                if win32.GetLastError() == win32.ERROR_OPERATION_ABORTED:
+                    return n.value  # canceled IO is no error
+                if n.value != len(data):
+                    raise SerialTimeoutException('Write timeout')
+                return n.value
+            else:
+                errorcode = win32.ERROR_SUCCESS if success else win32.GetLastError()
+                if errorcode in (win32.ERROR_INVALID_USER_BUFFER, win32.ERROR_NOT_ENOUGH_MEMORY,
+                                 win32.ERROR_OPERATION_ABORTED):
+                    return 0
+                elif errorcode in (win32.ERROR_SUCCESS, win32.ERROR_IO_PENDING):
+                    # no info on true length provided by OS function in async mode
+                    return len(data)
+                else:
+                    raise SerialException("WriteFile failed ({!r})".format(ctypes.WinError()))
+        else:
+            return 0
+
+    def flush(self):
+        """\
+        Flush of file like objects. In this case, wait until all data
+        is written.
+        """
+        while self.out_waiting:
+            time.sleep(0.05)
+        # XXX could also use WaitCommEvent with mask EV_TXEMPTY, but it would
+        # require overlapped IO and it's also only possible to set a single mask
+        # on the port---
+
+    def reset_input_buffer(self):
+        """Clear input buffer, discarding all that is in the buffer."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        win32.PurgeComm(self._port_handle, win32.PURGE_RXCLEAR | win32.PURGE_RXABORT)
+
+    def reset_output_buffer(self):
+        """\
+        Clear output buffer, aborting the current output and discarding all
+        that is in the buffer.
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        win32.PurgeComm(self._port_handle, win32.PURGE_TXCLEAR | win32.PURGE_TXABORT)
+
+    def _update_break_state(self):
+        """Set break: Controls TXD. When active, to transmitting is possible."""
+        if not self.is_open:
+            raise PortNotOpenError()
+        if self._break_state:
+            win32.SetCommBreak(self._port_handle)
+        else:
+            win32.ClearCommBreak(self._port_handle)
+
+    def _update_rts_state(self):
+        """Set terminal status line: Request To Send"""
+        if self._rts_state:
+            win32.EscapeCommFunction(self._port_handle, win32.SETRTS)
+        else:
+            win32.EscapeCommFunction(self._port_handle, win32.CLRRTS)
+
+    def _update_dtr_state(self):
+        """Set terminal status line: Data Terminal Ready"""
+        if self._dtr_state:
+            win32.EscapeCommFunction(self._port_handle, win32.SETDTR)
+        else:
+            win32.EscapeCommFunction(self._port_handle, win32.CLRDTR)
+
+    def _GetCommModemStatus(self):
+        if not self.is_open:
+            raise PortNotOpenError()
+        stat = win32.DWORD()
+        win32.GetCommModemStatus(self._port_handle, ctypes.byref(stat))
+        return stat.value
+
+    @property
+    def cts(self):
+        """Read terminal status line: Clear To Send"""
+        return win32.MS_CTS_ON & self._GetCommModemStatus() != 0
+
+    @property
+    def dsr(self):
+        """Read terminal status line: Data Set Ready"""
+        return win32.MS_DSR_ON & self._GetCommModemStatus() != 0
+
+    @property
+    def ri(self):
+        """Read terminal status line: Ring Indicator"""
+        return win32.MS_RING_ON & self._GetCommModemStatus() != 0
+
+    @property
+    def cd(self):
+        """Read terminal status line: Carrier Detect"""
+        return win32.MS_RLSD_ON & self._GetCommModemStatus() != 0
+
+    # - - platform specific - - - -
+
+    def set_buffer_size(self, rx_size=4096, tx_size=None):
+        """\
+        Recommend a buffer size to the driver (device driver can ignore this
+        value). Must be called after the port is opened.
+        """
+        if tx_size is None:
+            tx_size = rx_size
+        win32.SetupComm(self._port_handle, rx_size, tx_size)
+
+    def set_output_flow_control(self, enable=True):
+        """\
+        Manually control flow - when software flow control is enabled.
+        This will do the same as if XON (true) or XOFF (false) are received
+        from the other device and control the transmission accordingly.
+        WARNING: this function is not portable to different platforms!
+        """
+        if not self.is_open:
+            raise PortNotOpenError()
+        if enable:
+            win32.EscapeCommFunction(self._port_handle, win32.SETXON)
+        else:
+            win32.EscapeCommFunction(self._port_handle, win32.SETXOFF)
+
+    @property
+    def out_waiting(self):
+        """Return how many bytes the in the outgoing buffer"""
+        flags = win32.DWORD()
+        comstat = win32.COMSTAT()
+        if not win32.ClearCommError(self._port_handle, ctypes.byref(flags), ctypes.byref(comstat)):
+            raise SerialException("ClearCommError failed ({!r})".format(ctypes.WinError()))
+        return comstat.cbOutQue
+
+    def _cancel_overlapped_io(self, overlapped):
+        """Cancel a blocking read operation, may be called from other thread"""
+        # check if read operation is pending
+        rc = win32.DWORD()
+        err = win32.GetOverlappedResult(
+            self._port_handle,
+            ctypes.byref(overlapped),
+            ctypes.byref(rc),
+            False)
+        if not err and win32.GetLastError() in (win32.ERROR_IO_PENDING, win32.ERROR_IO_INCOMPLETE):
+            # cancel, ignoring any errors (e.g. it may just have finished on its own)
+            win32.CancelIoEx(self._port_handle, overlapped)
+
+    def cancel_read(self):
+        """Cancel a blocking read operation, may be called from other thread"""
+        self._cancel_overlapped_io(self._overlapped_read)
+
+    def cancel_write(self):
+        """Cancel a blocking write operation, may be called from other thread"""
+        self._cancel_overlapped_io(self._overlapped_write)
+
+    @SerialBase.exclusive.setter
+    def exclusive(self, exclusive):
+        """Change the exclusive access setting."""
+        if exclusive is not None and not exclusive:
+            raise ValueError('win32 only supports exclusive access (not: {})'.format(exclusive))
+        else:
+            serial.SerialBase.exclusive.__set__(self, exclusive)
diff --git a/modbus/serial/threaded/__init__.py b/modbus/serial/threaded/__init__.py
new file mode 100644
index 00000000..b8940b6d
--- /dev/null
+++ b/modbus/serial/threaded/__init__.py
@@ -0,0 +1,297 @@
+#!/usr/bin/env python3
+#
+# Working with threading and pySerial
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2015-2016 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+"""\
+Support threading with serial ports.
+"""
+from __future__ import absolute_import
+
+import serial
+import threading
+
+
+class Protocol(object):
+    """\
+    Protocol as used by the ReaderThread. This base class provides empty
+    implementations of all methods.
+    """
+
+    def connection_made(self, transport):
+        """Called when reader thread is started"""
+
+    def data_received(self, data):
+        """Called with snippets received from the serial port"""
+
+    def connection_lost(self, exc):
+        """\
+        Called when the serial port is closed or the reader loop terminated
+        otherwise.
+        """
+        if isinstance(exc, Exception):
+            raise exc
+
+
+class Packetizer(Protocol):
+    """
+    Read binary packets from serial port. Packets are expected to be terminated
+    with a TERMINATOR byte (null byte by default).
+
+    The class also keeps track of the transport.
+    """
+
+    TERMINATOR = b'\0'
+
+    def __init__(self):
+        self.buffer = bytearray()
+        self.transport = None
+
+    def connection_made(self, transport):
+        """Store transport"""
+        self.transport = transport
+
+    def connection_lost(self, exc):
+        """Forget transport"""
+        self.transport = None
+        super(Packetizer, self).connection_lost(exc)
+
+    def data_received(self, data):
+        """Buffer received data, find TERMINATOR, call handle_packet"""
+        self.buffer.extend(data)
+        while self.TERMINATOR in self.buffer:
+            packet, self.buffer = self.buffer.split(self.TERMINATOR, 1)
+            self.handle_packet(packet)
+
+    def handle_packet(self, packet):
+        """Process packets - to be overridden by subclassing"""
+        raise NotImplementedError('please implement functionality in handle_packet')
+
+
+class FramedPacket(Protocol):
+    """
+    Read binary packets. Packets are expected to have a start and stop marker.
+
+    The class also keeps track of the transport.
+    """
+
+    START = b'('
+    STOP = b')'
+
+    def __init__(self):
+        self.packet = bytearray()
+        self.in_packet = False
+        self.transport = None
+
+    def connection_made(self, transport):
+        """Store transport"""
+        self.transport = transport
+
+    def connection_lost(self, exc):
+        """Forget transport"""
+        self.transport = None
+        self.in_packet = False
+        del self.packet[:]
+        super(FramedPacket, self).connection_lost(exc)
+
+    def data_received(self, data):
+        """Find data enclosed in START/STOP, call handle_packet"""
+        for byte in serial.iterbytes(data):
+            if byte == self.START:
+                self.in_packet = True
+            elif byte == self.STOP:
+                self.in_packet = False
+                self.handle_packet(bytes(self.packet)) # make read-only copy
+                del self.packet[:]
+            elif self.in_packet:
+                self.packet.extend(byte)
+            else:
+                self.handle_out_of_packet_data(byte)
+
+    def handle_packet(self, packet):
+        """Process packets - to be overridden by subclassing"""
+        raise NotImplementedError('please implement functionality in handle_packet')
+
+    def handle_out_of_packet_data(self, data):
+        """Process data that is received outside of packets"""
+        pass
+
+
+class LineReader(Packetizer):
+    """
+    Read and write (Unicode) lines from/to serial port.
+    The encoding is applied.
+    """
+
+    TERMINATOR = b'\r\n'
+    ENCODING = 'utf-8'
+    UNICODE_HANDLING = 'replace'
+
+    def handle_packet(self, packet):
+        self.handle_line(packet.decode(self.ENCODING, self.UNICODE_HANDLING))
+
+    def handle_line(self, line):
+        """Process one line - to be overridden by subclassing"""
+        raise NotImplementedError('please implement functionality in handle_line')
+
+    def write_line(self, text):
+        """
+        Write text to the transport. ``text`` is a Unicode string and the encoding
+        is applied before sending ans also the newline is append.
+        """
+        # + is not the best choice but bytes does not support % or .format in py3 and we want a single write call
+        self.transport.write(text.encode(self.ENCODING, self.UNICODE_HANDLING) + self.TERMINATOR)
+
+
+class ReaderThread(threading.Thread):
+    """\
+    Implement a serial port read loop and dispatch to a Protocol instance (like
+    the asyncio.Protocol) but do it with threads.
+
+    Calls to close() will close the serial port but it is also possible to just
+    stop() this thread and continue the serial port instance otherwise.
+    """
+
+    def __init__(self, serial_instance, protocol_factory):
+        """\
+        Initialize thread.
+
+        Note that the serial_instance' timeout is set to one second!
+        Other settings are not changed.
+        """
+        super(ReaderThread, self).__init__()
+        self.daemon = True
+        self.serial = serial_instance
+        self.protocol_factory = protocol_factory
+        self.alive = True
+        self._lock = threading.Lock()
+        self._connection_made = threading.Event()
+        self.protocol = None
+
+    def stop(self):
+        """Stop the reader thread"""
+        self.alive = False
+        if hasattr(self.serial, 'cancel_read'):
+            self.serial.cancel_read()
+        self.join(2)
+
+    def run(self):
+        """Reader loop"""
+        if not hasattr(self.serial, 'cancel_read'):
+            self.serial.timeout = 1
+        self.protocol = self.protocol_factory()
+        try:
+            self.protocol.connection_made(self)
+        except Exception as e:
+            self.alive = False
+            self.protocol.connection_lost(e)
+            self._connection_made.set()
+            return
+        error = None
+        self._connection_made.set()
+        while self.alive and self.serial.is_open:
+            try:
+                # read all that is there or wait for one byte (blocking)
+                data = self.serial.read(self.serial.in_waiting or 1)
+            except serial.SerialException as e:
+                # probably some I/O problem such as disconnected USB serial
+                # adapters -> exit
+                error = e
+                break
+            else:
+                if data:
+                    # make a separated try-except for called user code
+                    try:
+                        self.protocol.data_received(data)
+                    except Exception as e:
+                        error = e
+                        break
+        self.alive = False
+        self.protocol.connection_lost(error)
+        self.protocol = None
+
+    def write(self, data):
+        """Thread safe writing (uses lock)"""
+        with self._lock:
+            return self.serial.write(data)
+
+    def close(self):
+        """Close the serial port and exit reader thread (uses lock)"""
+        # use the lock to let other threads finish writing
+        with self._lock:
+            # first stop reading, so that closing can be done on idle port
+            self.stop()
+            self.serial.close()
+
+    def connect(self):
+        """
+        Wait until connection is set up and return the transport and protocol
+        instances.
+        """
+        if self.alive:
+            self._connection_made.wait()
+            if not self.alive:
+                raise RuntimeError('connection_lost already called')
+            return (self, self.protocol)
+        else:
+            raise RuntimeError('already stopped')
+
+    # - -  context manager, returns protocol
+
+    def __enter__(self):
+        """\
+        Enter context handler. May raise RuntimeError in case the connection
+        could not be created.
+        """
+        self.start()
+        self._connection_made.wait()
+        if not self.alive:
+            raise RuntimeError('connection_lost already called')
+        return self.protocol
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        """Leave context: close port"""
+        self.close()
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# test
+if __name__ == '__main__':
+    # pylint: disable=wrong-import-position
+    import sys
+    import time
+    import traceback
+
+    #~ PORT = 'spy:///dev/ttyUSB0'
+    PORT = 'loop://'
+
+    class PrintLines(LineReader):
+        def connection_made(self, transport):
+            super(PrintLines, self).connection_made(transport)
+            sys.stdout.write('port opened\n')
+            self.write_line('hello world')
+
+        def handle_line(self, data):
+            sys.stdout.write('line received: {!r}\n'.format(data))
+
+        def connection_lost(self, exc):
+            if exc:
+                traceback.print_exc(exc)
+            sys.stdout.write('port closed\n')
+
+    ser = serial.serial_for_url(PORT, baudrate=115200, timeout=1)
+    with ReaderThread(ser, PrintLines) as protocol:
+        protocol.write_line('hello')
+        time.sleep(2)
+
+    # alternative usage
+    ser = serial.serial_for_url(PORT, baudrate=115200, timeout=1)
+    t = ReaderThread(ser, PrintLines)
+    t.start()
+    transport, protocol = t.connect()
+    protocol.write_line('hello')
+    time.sleep(2)
+    t.close()
diff --git a/modbus/serial/tools/__init__.py b/modbus/serial/tools/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modbus/serial/tools/hexlify_codec.py b/modbus/serial/tools/hexlify_codec.py
new file mode 100644
index 00000000..bd8f6b0d
--- /dev/null
+++ b/modbus/serial/tools/hexlify_codec.py
@@ -0,0 +1,126 @@
+#! python
+#
+# This is a codec to create and decode hexdumps with spaces between characters. used by miniterm.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2015-2016 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+"""\
+Python 'hex' Codec - 2-digit hex with spaces content transfer encoding.
+
+Encode and decode may be a bit missleading at first sight...
+
+The textual representation is a hex dump: e.g. "40 41"
+The "encoded" data of this is the binary form, e.g. b"@A"
+
+Therefore decoding is binary to text and thus converting binary data to hex dump.
+
+"""
+
+from __future__ import absolute_import
+
+import codecs
+import serial
+
+
+try:
+    unicode
+except (NameError, AttributeError):
+    unicode = str       # for Python 3, pylint: disable=redefined-builtin,invalid-name
+
+
+HEXDIGITS = '0123456789ABCDEF'
+
+
+# Codec APIs
+
+def hex_encode(data, errors='strict'):
+    """'40 41 42' -> b'@ab'"""
+    return (serial.to_bytes([int(h, 16) for h in data.split()]), len(data))
+
+
+def hex_decode(data, errors='strict'):
+    """b'@ab' -> '40 41 42'"""
+    return (unicode(''.join('{:02X} '.format(ord(b)) for b in serial.iterbytes(data))), len(data))
+
+
+class Codec(codecs.Codec):
+    def encode(self, data, errors='strict'):
+        """'40 41 42' -> b'@ab'"""
+        return serial.to_bytes([int(h, 16) for h in data.split()])
+
+    def decode(self, data, errors='strict'):
+        """b'@ab' -> '40 41 42'"""
+        return unicode(''.join('{:02X} '.format(ord(b)) for b in serial.iterbytes(data)))
+
+
+class IncrementalEncoder(codecs.IncrementalEncoder):
+    """Incremental hex encoder"""
+
+    def __init__(self, errors='strict'):
+        self.errors = errors
+        self.state = 0
+
+    def reset(self):
+        self.state = 0
+
+    def getstate(self):
+        return self.state
+
+    def setstate(self, state):
+        self.state = state
+
+    def encode(self, data, final=False):
+        """\
+        Incremental encode, keep track of digits and emit a byte when a pair
+        of hex digits is found. The space is optional unless the error
+        handling is defined to be 'strict'.
+        """
+        state = self.state
+        encoded = []
+        for c in data.upper():
+            if c in HEXDIGITS:
+                z = HEXDIGITS.index(c)
+                if state:
+                    encoded.append(z + (state & 0xf0))
+                    state = 0
+                else:
+                    state = 0x100 + (z << 4)
+            elif c == ' ':      # allow spaces to separate values
+                if state and self.errors == 'strict':
+                    raise UnicodeError('odd number of hex digits')
+                state = 0
+            else:
+                if self.errors == 'strict':
+                    raise UnicodeError('non-hex digit found: {!r}'.format(c))
+        self.state = state
+        return serial.to_bytes(encoded)
+
+
+class IncrementalDecoder(codecs.IncrementalDecoder):
+    """Incremental decoder"""
+    def decode(self, data, final=False):
+        return unicode(''.join('{:02X} '.format(ord(b)) for b in serial.iterbytes(data)))
+
+
+class StreamWriter(Codec, codecs.StreamWriter):
+    """Combination of hexlify codec and StreamWriter"""
+
+
+class StreamReader(Codec, codecs.StreamReader):
+    """Combination of hexlify codec and StreamReader"""
+
+
+def getregentry():
+    """encodings module API"""
+    return codecs.CodecInfo(
+        name='hexlify',
+        encode=hex_encode,
+        decode=hex_decode,
+        incrementalencoder=IncrementalEncoder,
+        incrementaldecoder=IncrementalDecoder,
+        streamwriter=StreamWriter,
+        streamreader=StreamReader,
+        #~ _is_text_encoding=True,
+    )
diff --git a/modbus/serial/tools/list_ports.py b/modbus/serial/tools/list_ports.py
new file mode 100644
index 00000000..0d7e3d41
--- /dev/null
+++ b/modbus/serial/tools/list_ports.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python
+#
+# Serial port enumeration. Console tool and backend selection.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2011-2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+"""\
+This module will provide a function called comports that returns an
+iterable (generator or list) that will enumerate available com ports. Note that
+on some systems non-existent ports may be listed.
+
+Additionally a grep function is supplied that can be used to search for ports
+based on their descriptions or hardware ID.
+"""
+
+from __future__ import absolute_import
+
+import sys
+import os
+import re
+
+# chose an implementation, depending on os
+#~ if sys.platform == 'cli':
+#~ else:
+if os.name == 'nt':  # sys.platform == 'win32':
+    from serial.tools.list_ports_windows import comports
+elif os.name == 'posix':
+    from serial.tools.list_ports_posix import comports
+#~ elif os.name == 'java':
+else:
+    raise ImportError("Sorry: no implementation for your platform ('{}') available".format(os.name))
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def grep(regexp, include_links=False):
+    """\
+    Search for ports using a regular expression. Port name, description and
+    hardware ID are searched. The function returns an iterable that returns the
+    same tuples as comport() would do.
+    """
+    r = re.compile(regexp, re.I)
+    for info in comports(include_links):
+        port, desc, hwid = info
+        if r.search(port) or r.search(desc) or r.search(hwid):
+            yield info
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def main():
+    import argparse
+
+    parser = argparse.ArgumentParser(description='Serial port enumeration')
+
+    parser.add_argument(
+        'regexp',
+        nargs='?',
+        help='only show ports that match this regex')
+
+    parser.add_argument(
+        '-v', '--verbose',
+        action='store_true',
+        help='show more messages')
+
+    parser.add_argument(
+        '-q', '--quiet',
+        action='store_true',
+        help='suppress all messages')
+
+    parser.add_argument(
+        '-n',
+        type=int,
+        help='only output the N-th entry')
+
+    parser.add_argument(
+        '-s', '--include-links',
+        action='store_true',
+        help='include entries that are symlinks to real devices')
+
+    args = parser.parse_args()
+
+    hits = 0
+    # get iteraror w/ or w/o filter
+    if args.regexp:
+        if not args.quiet:
+            sys.stderr.write("Filtered list with regexp: {!r}\n".format(args.regexp))
+        iterator = sorted(grep(args.regexp, include_links=args.include_links))
+    else:
+        iterator = sorted(comports(include_links=args.include_links))
+    # list them
+    for n, (port, desc, hwid) in enumerate(iterator, 1):
+        if args.n is None or args.n == n:
+            sys.stdout.write("{:20}\n".format(port))
+            if args.verbose:
+                sys.stdout.write("    desc: {}\n".format(desc))
+                sys.stdout.write("    hwid: {}\n".format(hwid))
+        hits += 1
+    if not args.quiet:
+        if hits:
+            sys.stderr.write("{} ports found\n".format(hits))
+        else:
+            sys.stderr.write("no ports found\n")
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# test
+if __name__ == '__main__':
+    main()
diff --git a/modbus/serial/tools/list_ports_common.py b/modbus/serial/tools/list_ports_common.py
new file mode 100644
index 00000000..617f3dc1
--- /dev/null
+++ b/modbus/serial/tools/list_ports_common.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python
+#
+# This is a helper module for the various platform dependent list_port
+# implementations.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+import re
+import glob
+import os
+import os.path
+
+
+def numsplit(text):
+    """\
+    Convert string into a list of texts and numbers in order to support a
+    natural sorting.
+    """
+    result = []
+    for group in re.split(r'(\d+)', text):
+        if group:
+            try:
+                group = int(group)
+            except ValueError:
+                pass
+            result.append(group)
+    return result
+
+
+class ListPortInfo(object):
+    """Info collection base class for serial ports"""
+
+    def __init__(self, device, skip_link_detection=False):
+        self.device = device
+        self.name = os.path.basename(device)
+        self.description = 'n/a'
+        self.hwid = 'n/a'
+        # USB specific data
+        self.vid = None
+        self.pid = None
+        self.serial_number = None
+        self.location = None
+        self.manufacturer = None
+        self.product = None
+        self.interface = None
+        # special handling for links
+        if not skip_link_detection and device is not None and os.path.islink(device):
+            self.hwid = 'LINK={}'.format(os.path.realpath(device))
+
+    def usb_description(self):
+        """return a short string to name the port based on USB info"""
+        if self.interface is not None:
+            return '{} - {}'.format(self.product, self.interface)
+        elif self.product is not None:
+            return self.product
+        else:
+            return self.name
+
+    def usb_info(self):
+        """return a string with USB related information about device"""
+        return 'USB VID:PID={:04X}:{:04X}{}{}'.format(
+            self.vid or 0,
+            self.pid or 0,
+            ' SER={}'.format(self.serial_number) if self.serial_number is not None else '',
+            ' LOCATION={}'.format(self.location) if self.location is not None else '')
+
+    def apply_usb_info(self):
+        """update description and hwid from USB data"""
+        self.description = self.usb_description()
+        self.hwid = self.usb_info()
+
+    def __eq__(self, other):
+        return isinstance(other, ListPortInfo) and self.device == other.device
+
+    def __hash__(self):
+        return hash(self.device)
+
+    def __lt__(self, other):
+        if not isinstance(other, ListPortInfo):
+            raise TypeError('unorderable types: {}() and {}()'.format(
+                type(self).__name__,
+                type(other).__name__))
+        return numsplit(self.device) < numsplit(other.device)
+
+    def __str__(self):
+        return '{} - {}'.format(self.device, self.description)
+
+    def __getitem__(self, index):
+        """Item access: backwards compatible -> (port, desc, hwid)"""
+        if index == 0:
+            return self.device
+        elif index == 1:
+            return self.description
+        elif index == 2:
+            return self.hwid
+        else:
+            raise IndexError('{} > 2'.format(index))
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def list_links(devices):
+    """\
+    search all /dev devices and look for symlinks to known ports already
+    listed in devices.
+    """
+    links = []
+    for device in glob.glob('/dev/*'):
+        if os.path.islink(device) and os.path.realpath(device) in devices:
+            links.append(device)
+    return links
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# test
+if __name__ == '__main__':
+    print(ListPortInfo('dummy'))
diff --git a/modbus/serial/tools/list_ports_linux.py b/modbus/serial/tools/list_ports_linux.py
new file mode 100644
index 00000000..c8c1cfc0
--- /dev/null
+++ b/modbus/serial/tools/list_ports_linux.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python
+#
+# This is a module that gathers a list of serial ports including details on
+# GNU/Linux systems.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2011-2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+import glob
+import os
+from serial.tools import list_ports_common
+
+
+class SysFS(list_ports_common.ListPortInfo):
+    """Wrapper for easy sysfs access and device info"""
+
+    def __init__(self, device):
+        super(SysFS, self).__init__(device)
+        # special handling for links
+        if device is not None and os.path.islink(device):
+            device = os.path.realpath(device)
+            is_link = True
+        else:
+            is_link = False
+        self.usb_device_path = None
+        if os.path.exists('/sys/class/tty/{}/device'.format(self.name)):
+            self.device_path = os.path.realpath('/sys/class/tty/{}/device'.format(self.name))
+            self.subsystem = os.path.basename(os.path.realpath(os.path.join(self.device_path, 'subsystem')))
+        else:
+            self.device_path = None
+            self.subsystem = None
+        # check device type
+        if self.subsystem == 'usb-serial':
+            self.usb_interface_path = os.path.dirname(self.device_path)
+        elif self.subsystem == 'usb':
+            self.usb_interface_path = self.device_path
+        else:
+            self.usb_interface_path = None
+        # fill-in info for USB devices
+        if self.usb_interface_path is not None:
+            self.usb_device_path = os.path.dirname(self.usb_interface_path)
+
+            try:
+                num_if = int(self.read_line(self.usb_device_path, 'bNumInterfaces'))
+            except ValueError:
+                num_if = 1
+
+            self.vid = int(self.read_line(self.usb_device_path, 'idVendor'), 16)
+            self.pid = int(self.read_line(self.usb_device_path, 'idProduct'), 16)
+            self.serial_number = self.read_line(self.usb_device_path, 'serial')
+            if num_if > 1:  # multi interface devices like FT4232
+                self.location = os.path.basename(self.usb_interface_path)
+            else:
+                self.location = os.path.basename(self.usb_device_path)
+
+            self.manufacturer = self.read_line(self.usb_device_path, 'manufacturer')
+            self.product = self.read_line(self.usb_device_path, 'product')
+            self.interface = self.read_line(self.usb_interface_path, 'interface')
+
+        if self.subsystem in ('usb', 'usb-serial'):
+            self.apply_usb_info()
+        #~ elif self.subsystem in ('pnp', 'amba'):  # PCI based devices, raspi
+        elif self.subsystem == 'pnp':  # PCI based devices
+            self.description = self.name
+            self.hwid = self.read_line(self.device_path, 'id')
+        elif self.subsystem == 'amba':  # raspi
+            self.description = self.name
+            self.hwid = os.path.basename(self.device_path)
+
+        if is_link:
+            self.hwid += ' LINK={}'.format(device)
+
+    def read_line(self, *args):
+        """\
+        Helper function to read a single line from a file.
+        One or more parameters are allowed, they are joined with os.path.join.
+        Returns None on errors..
+        """
+        try:
+            with open(os.path.join(*args)) as f:
+                line = f.readline().strip()
+            return line
+        except IOError:
+            return None
+
+
+def comports(include_links=False):
+    devices = glob.glob('/dev/ttyS*')           # built-in serial ports
+    devices.extend(glob.glob('/dev/ttyUSB*'))   # usb-serial with own driver
+    devices.extend(glob.glob('/dev/ttyXRUSB*')) # xr-usb-serial port exar (DELL Edge 3001)
+    devices.extend(glob.glob('/dev/ttyACM*'))   # usb-serial with CDC-ACM profile
+    devices.extend(glob.glob('/dev/ttyAMA*'))   # ARM internal port (raspi)
+    devices.extend(glob.glob('/dev/rfcomm*'))   # BT serial devices
+    devices.extend(glob.glob('/dev/ttyAP*'))    # Advantech multi-port serial controllers
+    if include_links:
+        devices.extend(list_ports_common.list_links(devices))
+    return [info
+            for info in [SysFS(d) for d in devices]
+            if info.subsystem != "platform"]    # hide non-present internal serial ports
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# test
+if __name__ == '__main__':
+    for info in sorted(comports()):
+        print("{0}: {0.subsystem}".format(info))
diff --git a/modbus/serial/tools/list_ports_osx.py b/modbus/serial/tools/list_ports_osx.py
new file mode 100644
index 00000000..51b4e8c0
--- /dev/null
+++ b/modbus/serial/tools/list_ports_osx.py
@@ -0,0 +1,299 @@
+#!/usr/bin/env python
+#
+# This is a module that gathers a list of serial ports including details on OSX
+#
+# code originally from https://github.com/makerbot/pyserial/tree/master/serial/tools
+# with contributions from cibomahto, dgs3, FarMcKon, tedbrandston
+# and modifications by cliechti, hoihu, hardkrash
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2013-2020
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+
+# List all of the callout devices in OS/X by querying IOKit.
+
+# See the following for a reference of how to do this:
+# http://developer.apple.com/library/mac/#documentation/DeviceDrivers/Conceptual/WorkingWSerial/WWSerial_SerialDevs/SerialDevices.html#//apple_ref/doc/uid/TP30000384-CIHGEAFD
+
+# More help from darwin_hid.py
+
+# Also see the 'IORegistryExplorer' for an idea of what we are actually searching
+
+from __future__ import absolute_import
+
+import ctypes
+
+from serial.tools import list_ports_common
+
+iokit = ctypes.cdll.LoadLibrary('/System/Library/Frameworks/IOKit.framework/IOKit')
+cf = ctypes.cdll.LoadLibrary('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation')
+
+# kIOMasterPortDefault is no longer exported in BigSur but no biggie, using NULL works just the same
+kIOMasterPortDefault = 0 # WAS: ctypes.c_void_p.in_dll(iokit, "kIOMasterPortDefault")
+kCFAllocatorDefault = ctypes.c_void_p.in_dll(cf, "kCFAllocatorDefault")
+
+kCFStringEncodingMacRoman = 0
+kCFStringEncodingUTF8 = 0x08000100
+
+# defined in `IOKit/usb/USBSpec.h`
+kUSBVendorString = 'USB Vendor Name'
+kUSBSerialNumberString = 'USB Serial Number'
+
+# `io_name_t` defined as `typedef char io_name_t[128];`
+# in `device/device_types.h`
+io_name_size = 128
+
+# defined in `mach/kern_return.h`
+KERN_SUCCESS = 0
+# kern_return_t defined as `typedef int kern_return_t;` in `mach/i386/kern_return.h`
+kern_return_t = ctypes.c_int
+
+iokit.IOServiceMatching.restype = ctypes.c_void_p
+
+iokit.IOServiceGetMatchingServices.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
+iokit.IOServiceGetMatchingServices.restype = kern_return_t
+
+iokit.IORegistryEntryGetParentEntry.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
+iokit.IOServiceGetMatchingServices.restype = kern_return_t
+
+iokit.IORegistryEntryCreateCFProperty.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32]
+iokit.IORegistryEntryCreateCFProperty.restype = ctypes.c_void_p
+
+iokit.IORegistryEntryGetPath.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
+iokit.IORegistryEntryGetPath.restype = kern_return_t
+
+iokit.IORegistryEntryGetName.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
+iokit.IORegistryEntryGetName.restype = kern_return_t
+
+iokit.IOObjectGetClass.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
+iokit.IOObjectGetClass.restype = kern_return_t
+
+iokit.IOObjectRelease.argtypes = [ctypes.c_void_p]
+
+
+cf.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int32]
+cf.CFStringCreateWithCString.restype = ctypes.c_void_p
+
+cf.CFStringGetCStringPtr.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
+cf.CFStringGetCStringPtr.restype = ctypes.c_char_p
+
+cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, ctypes.c_uint32]
+cf.CFStringGetCString.restype = ctypes.c_bool
+
+cf.CFNumberGetValue.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p]
+cf.CFNumberGetValue.restype = ctypes.c_void_p
+
+# void CFRelease ( CFTypeRef cf );
+cf.CFRelease.argtypes = [ctypes.c_void_p]
+cf.CFRelease.restype = None
+
+# CFNumber type defines
+kCFNumberSInt8Type = 1
+kCFNumberSInt16Type = 2
+kCFNumberSInt32Type = 3
+kCFNumberSInt64Type = 4
+
+
+def get_string_property(device_type, property):
+    """
+    Search the given device for the specified string property
+
+    @param device_type Type of Device
+    @param property String to search for
+    @return Python string containing the value, or None if not found.
+    """
+    key = cf.CFStringCreateWithCString(
+            kCFAllocatorDefault,
+            property.encode("utf-8"),
+            kCFStringEncodingUTF8)
+
+    CFContainer = iokit.IORegistryEntryCreateCFProperty(
+            device_type,
+            key,
+            kCFAllocatorDefault,
+            0)
+    output = None
+
+    if CFContainer:
+        output = cf.CFStringGetCStringPtr(CFContainer, 0)
+        if output is not None:
+            output = output.decode('utf-8')
+        else:
+            buffer = ctypes.create_string_buffer(io_name_size);
+            success = cf.CFStringGetCString(CFContainer, ctypes.byref(buffer), io_name_size, kCFStringEncodingUTF8)
+            if success:
+                output = buffer.value.decode('utf-8')
+        cf.CFRelease(CFContainer)
+    return output
+
+
+def get_int_property(device_type, property, cf_number_type):
+    """
+    Search the given device for the specified string property
+
+    @param device_type Device to search
+    @param property String to search for
+    @param cf_number_type CFType number
+
+    @return Python string containing the value, or None if not found.
+    """
+    key = cf.CFStringCreateWithCString(
+            kCFAllocatorDefault,
+            property.encode("utf-8"),
+            kCFStringEncodingUTF8)
+
+    CFContainer = iokit.IORegistryEntryCreateCFProperty(
+            device_type,
+            key,
+            kCFAllocatorDefault,
+            0)
+
+    if CFContainer:
+        if (cf_number_type == kCFNumberSInt32Type):
+            number = ctypes.c_uint32()
+        elif (cf_number_type == kCFNumberSInt16Type):
+            number = ctypes.c_uint16()
+        cf.CFNumberGetValue(CFContainer, cf_number_type, ctypes.byref(number))
+        cf.CFRelease(CFContainer)
+        return number.value
+    return None
+
+def IORegistryEntryGetName(device):
+    devicename = ctypes.create_string_buffer(io_name_size);
+    res = iokit.IORegistryEntryGetName(device, ctypes.byref(devicename))
+    if res != KERN_SUCCESS:
+        return None
+    # this works in python2 but may not be valid. Also I don't know if
+    # this encoding is guaranteed. It may be dependent on system locale.
+    return devicename.value.decode('utf-8')
+
+def IOObjectGetClass(device):
+    classname = ctypes.create_string_buffer(io_name_size)
+    iokit.IOObjectGetClass(device, ctypes.byref(classname))
+    return classname.value
+
+def GetParentDeviceByType(device, parent_type):
+    """ Find the first parent of a device that implements the parent_type
+        @param IOService Service to inspect
+        @return Pointer to the parent type, or None if it was not found.
+    """
+    # First, try to walk up the IOService tree to find a parent of this device that is a IOUSBDevice.
+    parent_type = parent_type.encode('utf-8')
+    while IOObjectGetClass(device) != parent_type:
+        parent = ctypes.c_void_p()
+        response = iokit.IORegistryEntryGetParentEntry(
+                device,
+                "IOService".encode("utf-8"),
+                ctypes.byref(parent))
+        # If we weren't able to find a parent for the device, we're done.
+        if response != KERN_SUCCESS:
+            return None
+        device = parent
+    return device
+
+
+def GetIOServicesByType(service_type):
+    """
+    returns iterator over specified service_type
+    """
+    serial_port_iterator = ctypes.c_void_p()
+
+    iokit.IOServiceGetMatchingServices(
+            kIOMasterPortDefault,
+            iokit.IOServiceMatching(service_type.encode('utf-8')),
+            ctypes.byref(serial_port_iterator))
+
+    services = []
+    while iokit.IOIteratorIsValid(serial_port_iterator):
+        service = iokit.IOIteratorNext(serial_port_iterator)
+        if not service:
+            break
+        services.append(service)
+    iokit.IOObjectRelease(serial_port_iterator)
+    return services
+
+
+def location_to_string(locationID):
+    """
+    helper to calculate port and bus number from locationID
+    """
+    loc = ['{}-'.format(locationID >> 24)]
+    while locationID & 0xf00000:
+        if len(loc) > 1:
+            loc.append('.')
+        loc.append('{}'.format((locationID >> 20) & 0xf))
+        locationID <<= 4
+    return ''.join(loc)
+
+
+class SuitableSerialInterface(object):
+    pass
+
+
+def scan_interfaces():
+    """
+    helper function to scan USB interfaces
+    returns a list of SuitableSerialInterface objects with name and id attributes
+    """
+    interfaces = []
+    for service in GetIOServicesByType('IOSerialBSDClient'):
+        device = get_string_property(service, "IOCalloutDevice")
+        if device:
+            usb_device = GetParentDeviceByType(service, "IOUSBInterface")
+            if usb_device:
+                name = get_string_property(usb_device, "USB Interface Name") or None
+                locationID = get_int_property(usb_device, "locationID", kCFNumberSInt32Type) or ''
+                i = SuitableSerialInterface()
+                i.id = locationID
+                i.name = name
+                interfaces.append(i)
+    return interfaces
+
+
+def search_for_locationID_in_interfaces(serial_interfaces, locationID):
+    for interface in serial_interfaces:
+        if (interface.id == locationID):
+            return interface.name
+    return None
+
+
+def comports(include_links=False):
+    # XXX include_links is currently ignored. are links in /dev even supported here?
+    # Scan for all iokit serial ports
+    services = GetIOServicesByType('IOSerialBSDClient')
+    ports = []
+    serial_interfaces = scan_interfaces()
+    for service in services:
+        # First, add the callout device file.
+        device = get_string_property(service, "IOCalloutDevice")
+        if device:
+            info = list_ports_common.ListPortInfo(device)
+            # If the serial port is implemented by IOUSBDevice
+            # NOTE IOUSBDevice was deprecated as of 10.11 and finally on Apple Silicon
+            # devices has been completely removed.  Thanks to @oskay for this patch.
+            usb_device = GetParentDeviceByType(service, "IOUSBHostDevice")
+            if not usb_device:
+                usb_device = GetParentDeviceByType(service, "IOUSBDevice")
+            if usb_device:
+                # fetch some useful informations from properties
+                info.vid = get_int_property(usb_device, "idVendor", kCFNumberSInt16Type)
+                info.pid = get_int_property(usb_device, "idProduct", kCFNumberSInt16Type)
+                info.serial_number = get_string_property(usb_device, kUSBSerialNumberString)
+                # We know this is a usb device, so the
+                # IORegistryEntryName should always be aliased to the
+                # usb product name string descriptor.
+                info.product = IORegistryEntryGetName(usb_device) or 'n/a'
+                info.manufacturer = get_string_property(usb_device, kUSBVendorString)
+                locationID = get_int_property(usb_device, "locationID", kCFNumberSInt32Type)
+                info.location = location_to_string(locationID)
+                info.interface = search_for_locationID_in_interfaces(serial_interfaces, locationID)
+                info.apply_usb_info()
+            ports.append(info)
+    return ports
+
+# test
+if __name__ == '__main__':
+    for port, desc, hwid in sorted(comports()):
+        print("{}: {} [{}]".format(port, desc, hwid))
diff --git a/modbus/serial/tools/list_ports_posix.py b/modbus/serial/tools/list_ports_posix.py
new file mode 100644
index 00000000..79bc8ed1
--- /dev/null
+++ b/modbus/serial/tools/list_ports_posix.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python
+#
+# This is a module that gathers a list of serial ports on POSIXy systems.
+# For some specific implementations, see also list_ports_linux, list_ports_osx
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2011-2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+"""\
+The ``comports`` function is expected to return an iterable that yields tuples
+of 3 strings: port name, human readable description and a hardware ID.
+
+As currently no method is known to get the second two strings easily, they are
+currently just identical to the port name.
+"""
+
+from __future__ import absolute_import
+
+import glob
+import sys
+import os
+from serial.tools import list_ports_common
+
+# try to detect the OS so that a device can be selected...
+plat = sys.platform.lower()
+
+if plat[:5] == 'linux':    # Linux (confirmed)  # noqa
+    from serial.tools.list_ports_linux import comports
+
+elif plat[:6] == 'darwin':   # OS X (confirmed)
+    from serial.tools.list_ports_osx import comports
+
+elif plat == 'cygwin':       # cygwin/win32
+    # cygwin accepts /dev/com* in many contexts
+    # (such as 'open' call, explicit 'ls'), but 'glob.glob'
+    # and bare 'ls' do not; so use /dev/ttyS* instead
+    def comports(include_links=False):
+        devices = glob.glob('/dev/ttyS*')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+elif plat[:7] == 'openbsd':    # OpenBSD
+    def comports(include_links=False):
+        devices = glob.glob('/dev/cua*')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+elif plat[:3] == 'bsd' or plat[:7] == 'freebsd':
+    def comports(include_links=False):
+        devices = glob.glob('/dev/cua*[!.init][!.lock]')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+elif plat[:6] == 'netbsd':   # NetBSD
+    def comports(include_links=False):
+        """scan for available ports. return a list of device names."""
+        devices = glob.glob('/dev/dty*')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+elif plat[:4] == 'irix':     # IRIX
+    def comports(include_links=False):
+        """scan for available ports. return a list of device names."""
+        devices = glob.glob('/dev/ttyf*')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+elif plat[:2] == 'hp':       # HP-UX (not tested)
+    def comports(include_links=False):
+        """scan for available ports. return a list of device names."""
+        devices = glob.glob('/dev/tty*p0')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+elif plat[:5] == 'sunos':    # Solaris/SunOS
+    def comports(include_links=False):
+        """scan for available ports. return a list of device names."""
+        devices = glob.glob('/dev/tty*c')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+elif plat[:3] == 'aix':      # AIX
+    def comports(include_links=False):
+        """scan for available ports. return a list of device names."""
+        devices = glob.glob('/dev/tty*')
+        if include_links:
+            devices.extend(list_ports_common.list_links(devices))
+        return [list_ports_common.ListPortInfo(d) for d in devices]
+
+else:
+    # platform detection has failed...
+    import serial
+    sys.stderr.write("""\
+don't know how to enumerate ttys on this system.
+! I you know how the serial ports are named send this information to
+! the author of this module:
+
+sys.platform = {!r}
+os.name = {!r}
+pySerial version = {}
+
+also add the naming scheme of the serial ports and with a bit luck you can get
+this module running...
+""".format(sys.platform, os.name, serial.VERSION))
+    raise ImportError("Sorry: no implementation for your platform ('{}') available".format(os.name))
+
+# test
+if __name__ == '__main__':
+    for port, desc, hwid in sorted(comports()):
+        print("{}: {} [{}]".format(port, desc, hwid))
diff --git a/modbus/serial/tools/list_ports_windows.py b/modbus/serial/tools/list_ports_windows.py
new file mode 100644
index 00000000..0b4a5b1e
--- /dev/null
+++ b/modbus/serial/tools/list_ports_windows.py
@@ -0,0 +1,427 @@
+#! python
+#
+# Enumerate serial ports on Windows including a human readable description
+# and hardware information.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2001-2016 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+# pylint: disable=invalid-name,too-few-public-methods
+import re
+import ctypes
+from ctypes.wintypes import BOOL
+from ctypes.wintypes import HWND
+from ctypes.wintypes import DWORD
+from ctypes.wintypes import WORD
+from ctypes.wintypes import LONG
+from ctypes.wintypes import ULONG
+from ctypes.wintypes import HKEY
+from ctypes.wintypes import BYTE
+import serial
+from serial.win32 import ULONG_PTR
+from serial.tools import list_ports_common
+
+
+def ValidHandle(value, func, arguments):
+    if value == 0:
+        raise ctypes.WinError()
+    return value
+
+
+NULL = 0
+HDEVINFO = ctypes.c_void_p
+LPCTSTR = ctypes.c_wchar_p
+PCTSTR = ctypes.c_wchar_p
+PTSTR = ctypes.c_wchar_p
+LPDWORD = PDWORD = ctypes.POINTER(DWORD)
+#~ LPBYTE = PBYTE = ctypes.POINTER(BYTE)
+LPBYTE = PBYTE = ctypes.c_void_p        # XXX avoids error about types
+
+ACCESS_MASK = DWORD
+REGSAM = ACCESS_MASK
+
+
+class GUID(ctypes.Structure):
+    _fields_ = [
+        ('Data1', DWORD),
+        ('Data2', WORD),
+        ('Data3', WORD),
+        ('Data4', BYTE * 8),
+    ]
+
+    def __str__(self):
+        return "{{{:08x}-{:04x}-{:04x}-{}-{}}}".format(
+            self.Data1,
+            self.Data2,
+            self.Data3,
+            ''.join(["{:02x}".format(d) for d in self.Data4[:2]]),
+            ''.join(["{:02x}".format(d) for d in self.Data4[2:]]),
+        )
+
+
+class SP_DEVINFO_DATA(ctypes.Structure):
+    _fields_ = [
+        ('cbSize', DWORD),
+        ('ClassGuid', GUID),
+        ('DevInst', DWORD),
+        ('Reserved', ULONG_PTR),
+    ]
+
+    def __str__(self):
+        return "ClassGuid:{} DevInst:{}".format(self.ClassGuid, self.DevInst)
+
+
+PSP_DEVINFO_DATA = ctypes.POINTER(SP_DEVINFO_DATA)
+
+PSP_DEVICE_INTERFACE_DETAIL_DATA = ctypes.c_void_p
+
+setupapi = ctypes.windll.LoadLibrary("setupapi")
+SetupDiDestroyDeviceInfoList = setupapi.SetupDiDestroyDeviceInfoList
+SetupDiDestroyDeviceInfoList.argtypes = [HDEVINFO]
+SetupDiDestroyDeviceInfoList.restype = BOOL
+
+SetupDiClassGuidsFromName = setupapi.SetupDiClassGuidsFromNameW
+SetupDiClassGuidsFromName.argtypes = [PCTSTR, ctypes.POINTER(GUID), DWORD, PDWORD]
+SetupDiClassGuidsFromName.restype = BOOL
+
+SetupDiEnumDeviceInfo = setupapi.SetupDiEnumDeviceInfo
+SetupDiEnumDeviceInfo.argtypes = [HDEVINFO, DWORD, PSP_DEVINFO_DATA]
+SetupDiEnumDeviceInfo.restype = BOOL
+
+SetupDiGetClassDevs = setupapi.SetupDiGetClassDevsW
+SetupDiGetClassDevs.argtypes = [ctypes.POINTER(GUID), PCTSTR, HWND, DWORD]
+SetupDiGetClassDevs.restype = HDEVINFO
+SetupDiGetClassDevs.errcheck = ValidHandle
+
+SetupDiGetDeviceRegistryProperty = setupapi.SetupDiGetDeviceRegistryPropertyW
+SetupDiGetDeviceRegistryProperty.argtypes = [HDEVINFO, PSP_DEVINFO_DATA, DWORD, PDWORD, PBYTE, DWORD, PDWORD]
+SetupDiGetDeviceRegistryProperty.restype = BOOL
+
+SetupDiGetDeviceInstanceId = setupapi.SetupDiGetDeviceInstanceIdW
+SetupDiGetDeviceInstanceId.argtypes = [HDEVINFO, PSP_DEVINFO_DATA, PTSTR, DWORD, PDWORD]
+SetupDiGetDeviceInstanceId.restype = BOOL
+
+SetupDiOpenDevRegKey = setupapi.SetupDiOpenDevRegKey
+SetupDiOpenDevRegKey.argtypes = [HDEVINFO, PSP_DEVINFO_DATA, DWORD, DWORD, DWORD, REGSAM]
+SetupDiOpenDevRegKey.restype = HKEY
+
+advapi32 = ctypes.windll.LoadLibrary("Advapi32")
+RegCloseKey = advapi32.RegCloseKey
+RegCloseKey.argtypes = [HKEY]
+RegCloseKey.restype = LONG
+
+RegQueryValueEx = advapi32.RegQueryValueExW
+RegQueryValueEx.argtypes = [HKEY, LPCTSTR, LPDWORD, LPDWORD, LPBYTE, LPDWORD]
+RegQueryValueEx.restype = LONG
+
+cfgmgr32 = ctypes.windll.LoadLibrary("Cfgmgr32")
+CM_Get_Parent = cfgmgr32.CM_Get_Parent
+CM_Get_Parent.argtypes = [PDWORD, DWORD, ULONG]
+CM_Get_Parent.restype = LONG
+
+CM_Get_Device_IDW = cfgmgr32.CM_Get_Device_IDW
+CM_Get_Device_IDW.argtypes = [DWORD, PTSTR, ULONG, ULONG]
+CM_Get_Device_IDW.restype = LONG
+
+CM_MapCrToWin32Err = cfgmgr32.CM_MapCrToWin32Err
+CM_MapCrToWin32Err.argtypes = [DWORD, DWORD]
+CM_MapCrToWin32Err.restype = DWORD
+
+
+DIGCF_PRESENT = 2
+DIGCF_DEVICEINTERFACE = 16
+INVALID_HANDLE_VALUE = 0
+ERROR_INSUFFICIENT_BUFFER = 122
+ERROR_NOT_FOUND = 1168
+SPDRP_HARDWAREID = 1
+SPDRP_FRIENDLYNAME = 12
+SPDRP_LOCATION_PATHS = 35
+SPDRP_MFG = 11
+DICS_FLAG_GLOBAL = 1
+DIREG_DEV = 0x00000001
+KEY_READ = 0x20019
+
+
+MAX_USB_DEVICE_TREE_TRAVERSAL_DEPTH = 5
+
+
+def get_parent_serial_number(child_devinst, child_vid, child_pid, depth=0, last_serial_number=None):
+    """ Get the serial number of the parent of a device.
+
+    Args:
+        child_devinst: The device instance handle to get the parent serial number of.
+        child_vid: The vendor ID of the child device.
+        child_pid: The product ID of the child device.
+        depth: The current iteration depth of the USB device tree.
+    """
+
+    # If the traversal depth is beyond the max, abandon attempting to find the serial number.
+    if depth > MAX_USB_DEVICE_TREE_TRAVERSAL_DEPTH:
+        return '' if not last_serial_number else last_serial_number
+
+    # Get the parent device instance.
+    devinst = DWORD()
+    ret = CM_Get_Parent(ctypes.byref(devinst), child_devinst, 0)
+
+    if ret:
+        win_error = CM_MapCrToWin32Err(DWORD(ret), DWORD(0))
+
+        # If there is no parent available, the child was the root device. We cannot traverse
+        # further.
+        if win_error == ERROR_NOT_FOUND:
+            return '' if not last_serial_number else last_serial_number
+
+        raise ctypes.WinError(win_error)
+
+    # Get the ID of the parent device and parse it for vendor ID, product ID, and serial number.
+    parentHardwareID = ctypes.create_unicode_buffer(250)
+
+    ret = CM_Get_Device_IDW(
+        devinst,
+        parentHardwareID,
+        ctypes.sizeof(parentHardwareID) - 1,
+        0)
+
+    if ret:
+        raise ctypes.WinError(CM_MapCrToWin32Err(DWORD(ret), DWORD(0)))
+
+    parentHardwareID_str = parentHardwareID.value
+    m = re.search(r'VID_([0-9a-f]{4})(&PID_([0-9a-f]{4}))?(&MI_(\d{2}))?(\\(.*))?',
+                  parentHardwareID_str,
+                  re.I)
+
+    # return early if we have no matches (likely malformed serial, traversed too far)
+    if not m:
+        return '' if not last_serial_number else last_serial_number
+
+    vid = None
+    pid = None
+    serial_number = None
+    if m.group(1):
+        vid = int(m.group(1), 16)
+    if m.group(3):
+        pid = int(m.group(3), 16)
+    if m.group(7):
+        serial_number = m.group(7)
+
+    # store what we found as a fallback for malformed serial values up the chain
+    found_serial_number = serial_number
+
+    # Check that the USB serial number only contains alpha-numeric characters. It may be a windows
+    # device ID (ephemeral ID).
+    if serial_number and not re.match(r'^\w+$', serial_number):
+        serial_number = None
+
+    if not vid or not pid:
+        # If pid and vid are not available at this device level, continue to the parent.
+        return get_parent_serial_number(devinst, child_vid, child_pid, depth + 1, found_serial_number)
+
+    if pid != child_pid or vid != child_vid:
+        # If the VID or PID has changed, we are no longer looking at the same physical device. The
+        # serial number is unknown.
+        return '' if not last_serial_number else last_serial_number
+
+    # In this case, the vid and pid of the parent device are identical to the child. However, if
+    # there still isn't a serial number available, continue to the next parent.
+    if not serial_number:
+        return get_parent_serial_number(devinst, child_vid, child_pid, depth + 1, found_serial_number)
+
+    # Finally, the VID and PID are identical to the child and a serial number is present, so return
+    # it.
+    return serial_number
+
+
+def iterate_comports():
+    """Return a generator that yields descriptions for serial ports"""
+    PortsGUIDs = (GUID * 8)()  # so far only seen one used, so hope 8 are enough...
+    ports_guids_size = DWORD()
+    if not SetupDiClassGuidsFromName(
+            "Ports",
+            PortsGUIDs,
+            ctypes.sizeof(PortsGUIDs),
+            ctypes.byref(ports_guids_size)):
+        raise ctypes.WinError()
+
+    ModemsGUIDs = (GUID * 8)()  # so far only seen one used, so hope 8 are enough...
+    modems_guids_size = DWORD()
+    if not SetupDiClassGuidsFromName(
+            "Modem",
+            ModemsGUIDs,
+            ctypes.sizeof(ModemsGUIDs),
+            ctypes.byref(modems_guids_size)):
+        raise ctypes.WinError()
+
+    GUIDs = PortsGUIDs[:ports_guids_size.value] + ModemsGUIDs[:modems_guids_size.value]
+
+    # repeat for all possible GUIDs
+    for index in range(len(GUIDs)):
+        bInterfaceNumber = None
+        g_hdi = SetupDiGetClassDevs(
+            ctypes.byref(GUIDs[index]),
+            None,
+            NULL,
+            DIGCF_PRESENT)  # was DIGCF_PRESENT|DIGCF_DEVICEINTERFACE which misses CDC ports
+
+        devinfo = SP_DEVINFO_DATA()
+        devinfo.cbSize = ctypes.sizeof(devinfo)
+        index = 0
+        while SetupDiEnumDeviceInfo(g_hdi, index, ctypes.byref(devinfo)):
+            index += 1
+
+            # get the real com port name
+            hkey = SetupDiOpenDevRegKey(
+                g_hdi,
+                ctypes.byref(devinfo),
+                DICS_FLAG_GLOBAL,
+                0,
+                DIREG_DEV,  # DIREG_DRV for SW info
+                KEY_READ)
+            port_name_buffer = ctypes.create_unicode_buffer(250)
+            port_name_length = ULONG(ctypes.sizeof(port_name_buffer))
+            RegQueryValueEx(
+                hkey,
+                "PortName",
+                None,
+                None,
+                ctypes.byref(port_name_buffer),
+                ctypes.byref(port_name_length))
+            RegCloseKey(hkey)
+
+            # unfortunately does this method also include parallel ports.
+            # we could check for names starting with COM or just exclude LPT
+            # and hope that other "unknown" names are serial ports...
+            if port_name_buffer.value.startswith('LPT'):
+                continue
+
+            # hardware ID
+            szHardwareID = ctypes.create_unicode_buffer(250)
+            # try to get ID that includes serial number
+            if not SetupDiGetDeviceInstanceId(
+                    g_hdi,
+                    ctypes.byref(devinfo),
+                    #~ ctypes.byref(szHardwareID),
+                    szHardwareID,
+                    ctypes.sizeof(szHardwareID) - 1,
+                    None):
+                # fall back to more generic hardware ID if that would fail
+                if not SetupDiGetDeviceRegistryProperty(
+                        g_hdi,
+                        ctypes.byref(devinfo),
+                        SPDRP_HARDWAREID,
+                        None,
+                        ctypes.byref(szHardwareID),
+                        ctypes.sizeof(szHardwareID) - 1,
+                        None):
+                    # Ignore ERROR_INSUFFICIENT_BUFFER
+                    if ctypes.GetLastError() != ERROR_INSUFFICIENT_BUFFER:
+                        raise ctypes.WinError()
+            # stringify
+            szHardwareID_str = szHardwareID.value
+
+            info = list_ports_common.ListPortInfo(port_name_buffer.value, skip_link_detection=True)
+
+            # in case of USB, make a more readable string, similar to that form
+            # that we also generate on other platforms
+            if szHardwareID_str.startswith('USB'):
+                m = re.search(r'VID_([0-9a-f]{4})(&PID_([0-9a-f]{4}))?(&MI_(\d{2}))?(\\(.*))?', szHardwareID_str, re.I)
+                if m:
+                    info.vid = int(m.group(1), 16)
+                    if m.group(3):
+                        info.pid = int(m.group(3), 16)
+                    if m.group(5):
+                        bInterfaceNumber = int(m.group(5))
+
+                    # Check that the USB serial number only contains alpha-numeric characters. It
+                    # may be a windows device ID (ephemeral ID) for composite devices.
+                    if m.group(7) and re.match(r'^\w+$', m.group(7)):
+                        info.serial_number = m.group(7)
+                    else:
+                        info.serial_number = get_parent_serial_number(devinfo.DevInst, info.vid, info.pid)
+
+                # calculate a location string
+                loc_path_str = ctypes.create_unicode_buffer(250)
+                if SetupDiGetDeviceRegistryProperty(
+                        g_hdi,
+                        ctypes.byref(devinfo),
+                        SPDRP_LOCATION_PATHS,
+                        None,
+                        ctypes.byref(loc_path_str),
+                        ctypes.sizeof(loc_path_str) - 1,
+                        None):
+                    m = re.finditer(r'USBROOT\((\w+)\)|#USB\((\w+)\)', loc_path_str.value)
+                    location = []
+                    for g in m:
+                        if g.group(1):
+                            location.append('{:d}'.format(int(g.group(1)) + 1))
+                        else:
+                            if len(location) > 1:
+                                location.append('.')
+                            else:
+                                location.append('-')
+                            location.append(g.group(2))
+                    if bInterfaceNumber is not None:
+                        location.append(':{}.{}'.format(
+                            'x',  # XXX how to determine correct bConfigurationValue?
+                            bInterfaceNumber))
+                    if location:
+                        info.location = ''.join(location)
+                info.hwid = info.usb_info()
+            elif szHardwareID_str.startswith('FTDIBUS'):
+                m = re.search(r'VID_([0-9a-f]{4})\+PID_([0-9a-f]{4})(\+(\w+))?', szHardwareID_str, re.I)
+                if m:
+                    info.vid = int(m.group(1), 16)
+                    info.pid = int(m.group(2), 16)
+                    if m.group(4):
+                        info.serial_number = m.group(4)
+                # USB location is hidden by FDTI driver :(
+                info.hwid = info.usb_info()
+            else:
+                info.hwid = szHardwareID_str
+
+            # friendly name
+            szFriendlyName = ctypes.create_unicode_buffer(250)
+            if SetupDiGetDeviceRegistryProperty(
+                    g_hdi,
+                    ctypes.byref(devinfo),
+                    SPDRP_FRIENDLYNAME,
+                    #~ SPDRP_DEVICEDESC,
+                    None,
+                    ctypes.byref(szFriendlyName),
+                    ctypes.sizeof(szFriendlyName) - 1,
+                    None):
+                info.description = szFriendlyName.value
+            #~ else:
+                # Ignore ERROR_INSUFFICIENT_BUFFER
+                #~ if ctypes.GetLastError() != ERROR_INSUFFICIENT_BUFFER:
+                    #~ raise IOError("failed to get details for %s (%s)" % (devinfo, szHardwareID.value))
+                # ignore errors and still include the port in the list, friendly name will be same as port name
+
+            # manufacturer
+            szManufacturer = ctypes.create_unicode_buffer(250)
+            if SetupDiGetDeviceRegistryProperty(
+                    g_hdi,
+                    ctypes.byref(devinfo),
+                    SPDRP_MFG,
+                    #~ SPDRP_DEVICEDESC,
+                    None,
+                    ctypes.byref(szManufacturer),
+                    ctypes.sizeof(szManufacturer) - 1,
+                    None):
+                info.manufacturer = szManufacturer.value
+            yield info
+        SetupDiDestroyDeviceInfoList(g_hdi)
+
+
+def comports(include_links=False):
+    """Return a list of info objects about serial ports"""
+    return list(iterate_comports())
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# test
+if __name__ == '__main__':
+    for port, desc, hwid in sorted(comports()):
+        print("{}: {} [{}]".format(port, desc, hwid))
diff --git a/modbus/serial/tools/miniterm.py b/modbus/serial/tools/miniterm.py
new file mode 100644
index 00000000..2cceff63
--- /dev/null
+++ b/modbus/serial/tools/miniterm.py
@@ -0,0 +1,1042 @@
+#!/usr/bin/env python
+#
+# Very simple serial terminal
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C)2002-2020 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+from __future__ import absolute_import
+
+import codecs
+import os
+import sys
+import threading
+
+import serial
+from serial.tools.list_ports import comports
+from serial.tools import hexlify_codec
+
+# pylint: disable=wrong-import-order,wrong-import-position
+
+codecs.register(lambda c: hexlify_codec.getregentry() if c == 'hexlify' else None)
+
+try:
+    raw_input
+except NameError:
+    # pylint: disable=redefined-builtin,invalid-name
+    raw_input = input   # in python3 it's "raw"
+    unichr = chr
+
+
+def key_description(character):
+    """generate a readable description for a key"""
+    ascii_code = ord(character)
+    if ascii_code < 32:
+        return 'Ctrl+{:c}'.format(ord('@') + ascii_code)
+    else:
+        return repr(character)
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+class ConsoleBase(object):
+    """OS abstraction for console (input/output codec, no echo)"""
+
+    def __init__(self):
+        if sys.version_info >= (3, 0):
+            self.byte_output = sys.stdout.buffer
+        else:
+            self.byte_output = sys.stdout
+        self.output = sys.stdout
+
+    def setup(self):
+        """Set console to read single characters, no echo"""
+
+    def cleanup(self):
+        """Restore default console settings"""
+
+    def getkey(self):
+        """Read a single key from the console"""
+        return None
+
+    def write_bytes(self, byte_string):
+        """Write bytes (already encoded)"""
+        self.byte_output.write(byte_string)
+        self.byte_output.flush()
+
+    def write(self, text):
+        """Write string"""
+        self.output.write(text)
+        self.output.flush()
+
+    def cancel(self):
+        """Cancel getkey operation"""
+
+    #  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -
+    # context manager:
+    # switch terminal temporary to normal mode (e.g. to get user input)
+
+    def __enter__(self):
+        self.cleanup()
+        return self
+
+    def __exit__(self, *args, **kwargs):
+        self.setup()
+
+
+if os.name == 'nt':  # noqa
+    import msvcrt
+    import ctypes
+    import platform
+
+    class Out(object):
+        """file-like wrapper that uses os.write"""
+
+        def __init__(self, fd):
+            self.fd = fd
+
+        def flush(self):
+            pass
+
+        def write(self, s):
+            os.write(self.fd, s)
+
+    class Console(ConsoleBase):
+        fncodes = {
+            ';': '\1bOP',  # F1
+            '<': '\1bOQ',  # F2
+            '=': '\1bOR',  # F3
+            '>': '\1bOS',  # F4
+            '?': '\1b[15~',  # F5
+            '@': '\1b[17~',  # F6
+            'A': '\1b[18~',  # F7
+            'B': '\1b[19~',  # F8
+            'C': '\1b[20~',  # F9
+            'D': '\1b[21~',  # F10
+        }
+        navcodes = {
+            'H': '\x1b[A',  # UP
+            'P': '\x1b[B',  # DOWN
+            'K': '\x1b[D',  # LEFT
+            'M': '\x1b[C',  # RIGHT
+            'G': '\x1b[H',  # HOME
+            'O': '\x1b[F',  # END
+            'R': '\x1b[2~',  # INSERT
+            'S': '\x1b[3~',  # DELETE
+            'I': '\x1b[5~',  # PGUP
+            'Q': '\x1b[6~',  # PGDN        
+        }
+        
+        def __init__(self):
+            super(Console, self).__init__()
+            self._saved_ocp = ctypes.windll.kernel32.GetConsoleOutputCP()
+            self._saved_icp = ctypes.windll.kernel32.GetConsoleCP()
+            ctypes.windll.kernel32.SetConsoleOutputCP(65001)
+            ctypes.windll.kernel32.SetConsoleCP(65001)
+            # ANSI handling available through SetConsoleMode since Windows 10 v1511 
+            # https://en.wikipedia.org/wiki/ANSI_escape_code#cite_note-win10th2-1
+            if platform.release() == '10' and int(platform.version().split('.')[2]) > 10586:
+                ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
+                import ctypes.wintypes as wintypes
+                if not hasattr(wintypes, 'LPDWORD'): # PY2
+                    wintypes.LPDWORD = ctypes.POINTER(wintypes.DWORD)
+                SetConsoleMode = ctypes.windll.kernel32.SetConsoleMode
+                GetConsoleMode = ctypes.windll.kernel32.GetConsoleMode
+                GetStdHandle = ctypes.windll.kernel32.GetStdHandle
+                mode = wintypes.DWORD()
+                GetConsoleMode(GetStdHandle(-11), ctypes.byref(mode))
+                if (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0:
+                    SetConsoleMode(GetStdHandle(-11), mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
+                    self._saved_cm = mode
+            self.output = codecs.getwriter('UTF-8')(Out(sys.stdout.fileno()), 'replace')
+            # the change of the code page is not propagated to Python, manually fix it
+            sys.stderr = codecs.getwriter('UTF-8')(Out(sys.stderr.fileno()), 'replace')
+            sys.stdout = self.output
+            self.output.encoding = 'UTF-8'  # needed for input
+
+        def __del__(self):
+            ctypes.windll.kernel32.SetConsoleOutputCP(self._saved_ocp)
+            ctypes.windll.kernel32.SetConsoleCP(self._saved_icp)
+            try:
+                ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), self._saved_cm)
+            except AttributeError: # in case no _saved_cm
+                pass
+
+        def getkey(self):
+            while True:
+                z = msvcrt.getwch()
+                if z == unichr(13):
+                    return unichr(10)
+                elif z is unichr(0) or z is unichr(0xe0):
+                    try:
+                        code = msvcrt.getwch()
+                        if z is unichr(0):
+                            return self.fncodes[code]
+                        else:
+                            return self.navcodes[code]
+                    except KeyError:
+                        pass
+                else:
+                    return z
+
+        def cancel(self):
+            # CancelIo, CancelSynchronousIo do not seem to work when using
+            # getwch, so instead, send a key to the window with the console
+            hwnd = ctypes.windll.kernel32.GetConsoleWindow()
+            ctypes.windll.user32.PostMessageA(hwnd, 0x100, 0x0d, 0)
+
+elif os.name == 'posix':
+    import atexit
+    import termios
+    import fcntl
+
+    class Console(ConsoleBase):
+        def __init__(self):
+            super(Console, self).__init__()
+            self.fd = sys.stdin.fileno()
+            self.old = termios.tcgetattr(self.fd)
+            atexit.register(self.cleanup)
+            if sys.version_info < (3, 0):
+                self.enc_stdin = codecs.getreader(sys.stdin.encoding)(sys.stdin)
+            else:
+                self.enc_stdin = sys.stdin
+
+        def setup(self):
+            new = termios.tcgetattr(self.fd)
+            new[3] = new[3] & ~termios.ICANON & ~termios.ECHO & ~termios.ISIG
+            new[6][termios.VMIN] = 1
+            new[6][termios.VTIME] = 0
+            termios.tcsetattr(self.fd, termios.TCSANOW, new)
+
+        def getkey(self):
+            c = self.enc_stdin.read(1)
+            if c == unichr(0x7f):
+                c = unichr(8)    # map the BS key (which yields DEL) to backspace
+            return c
+
+        def cancel(self):
+            fcntl.ioctl(self.fd, termios.TIOCSTI, b'\0')
+
+        def cleanup(self):
+            termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.old)
+
+else:
+    raise NotImplementedError(
+        'Sorry no implementation for your platform ({}) available.'.format(sys.platform))
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+class Transform(object):
+    """do-nothing: forward all data unchanged"""
+    def rx(self, text):
+        """text received from serial port"""
+        return text
+
+    def tx(self, text):
+        """text to be sent to serial port"""
+        return text
+
+    def echo(self, text):
+        """text to be sent but displayed on console"""
+        return text
+
+
+class CRLF(Transform):
+    """ENTER sends CR+LF"""
+
+    def tx(self, text):
+        return text.replace('\n', '\r\n')
+
+
+class CR(Transform):
+    """ENTER sends CR"""
+
+    def rx(self, text):
+        return text.replace('\r', '\n')
+
+    def tx(self, text):
+        return text.replace('\n', '\r')
+
+
+class LF(Transform):
+    """ENTER sends LF"""
+
+
+class NoTerminal(Transform):
+    """remove typical terminal control codes from input"""
+
+    REPLACEMENT_MAP = dict((x, 0x2400 + x) for x in range(32) if unichr(x) not in '\r\n\b\t')
+    REPLACEMENT_MAP.update(
+        {
+            0x7F: 0x2421,  # DEL
+            0x9B: 0x2425,  # CSI
+        })
+
+    def rx(self, text):
+        return text.translate(self.REPLACEMENT_MAP)
+
+    echo = rx
+
+
+class NoControls(NoTerminal):
+    """Remove all control codes, incl. CR+LF"""
+
+    REPLACEMENT_MAP = dict((x, 0x2400 + x) for x in range(32))
+    REPLACEMENT_MAP.update(
+        {
+            0x20: 0x2423,  # visual space
+            0x7F: 0x2421,  # DEL
+            0x9B: 0x2425,  # CSI
+        })
+
+
+class Printable(Transform):
+    """Show decimal code for all non-ASCII characters and replace most control codes"""
+
+    def rx(self, text):
+        r = []
+        for c in text:
+            if ' ' <= c < '\x7f' or c in '\r\n\b\t':
+                r.append(c)
+            elif c < ' ':
+                r.append(unichr(0x2400 + ord(c)))
+            else:
+                r.extend(unichr(0x2080 + ord(d) - 48) for d in '{:d}'.format(ord(c)))
+                r.append(' ')
+        return ''.join(r)
+
+    echo = rx
+
+
+class Colorize(Transform):
+    """Apply different colors for received and echo"""
+
+    def __init__(self):
+        # XXX make it configurable, use colorama?
+        self.input_color = '\x1b[37m'
+        self.echo_color = '\x1b[31m'
+
+    def rx(self, text):
+        return self.input_color + text
+
+    def echo(self, text):
+        return self.echo_color + text
+
+
+class DebugIO(Transform):
+    """Print what is sent and received"""
+
+    def rx(self, text):
+        sys.stderr.write(' [RX:{!r}] '.format(text))
+        sys.stderr.flush()
+        return text
+
+    def tx(self, text):
+        sys.stderr.write(' [TX:{!r}] '.format(text))
+        sys.stderr.flush()
+        return text
+
+
+# other ideas:
+# - add date/time for each newline
+# - insert newline after: a) timeout b) packet end character
+
+EOL_TRANSFORMATIONS = {
+    'crlf': CRLF,
+    'cr': CR,
+    'lf': LF,
+}
+
+TRANSFORMATIONS = {
+    'direct': Transform,    # no transformation
+    'default': NoTerminal,
+    'nocontrol': NoControls,
+    'printable': Printable,
+    'colorize': Colorize,
+    'debug': DebugIO,
+}
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def ask_for_port():
+    """\
+    Show a list of ports and ask the user for a choice. To make selection
+    easier on systems with long device names, also allow the input of an
+    index.
+    """
+    sys.stderr.write('\n--- Available ports:\n')
+    ports = []
+    for n, (port, desc, hwid) in enumerate(sorted(comports()), 1):
+        sys.stderr.write('--- {:2}: {:20} {!r}\n'.format(n, port, desc))
+        ports.append(port)
+    while True:
+        port = raw_input('--- Enter port index or full name: ')
+        try:
+            index = int(port) - 1
+            if not 0 <= index < len(ports):
+                sys.stderr.write('--- Invalid index!\n')
+                continue
+        except ValueError:
+            pass
+        else:
+            port = ports[index]
+        return port
+
+
+class Miniterm(object):
+    """\
+    Terminal application. Copy data from serial port to console and vice versa.
+    Handle special keys from the console to show menu etc.
+    """
+
+    def __init__(self, serial_instance, echo=False, eol='crlf', filters=()):
+        self.console = Console()
+        self.serial = serial_instance
+        self.echo = echo
+        self.raw = False
+        self.input_encoding = 'UTF-8'
+        self.output_encoding = 'UTF-8'
+        self.eol = eol
+        self.filters = filters
+        self.update_transformations()
+        self.exit_character = unichr(0x1d)  # GS/CTRL+]
+        self.menu_character = unichr(0x14)  # Menu: CTRL+T
+        self.alive = None
+        self._reader_alive = None
+        self.receiver_thread = None
+        self.rx_decoder = None
+        self.tx_decoder = None
+
+    def _start_reader(self):
+        """Start reader thread"""
+        self._reader_alive = True
+        # start serial->console thread
+        self.receiver_thread = threading.Thread(target=self.reader, name='rx')
+        self.receiver_thread.daemon = True
+        self.receiver_thread.start()
+
+    def _stop_reader(self):
+        """Stop reader thread only, wait for clean exit of thread"""
+        self._reader_alive = False
+        if hasattr(self.serial, 'cancel_read'):
+            self.serial.cancel_read()
+        self.receiver_thread.join()
+
+    def start(self):
+        """start worker threads"""
+        self.alive = True
+        self._start_reader()
+        # enter console->serial loop
+        self.transmitter_thread = threading.Thread(target=self.writer, name='tx')
+        self.transmitter_thread.daemon = True
+        self.transmitter_thread.start()
+        self.console.setup()
+
+    def stop(self):
+        """set flag to stop worker threads"""
+        self.alive = False
+
+    def join(self, transmit_only=False):
+        """wait for worker threads to terminate"""
+        self.transmitter_thread.join()
+        if not transmit_only:
+            if hasattr(self.serial, 'cancel_read'):
+                self.serial.cancel_read()
+            self.receiver_thread.join()
+
+    def close(self):
+        self.serial.close()
+
+    def update_transformations(self):
+        """take list of transformation classes and instantiate them for rx and tx"""
+        transformations = [EOL_TRANSFORMATIONS[self.eol]] + [TRANSFORMATIONS[f]
+                                                             for f in self.filters]
+        self.tx_transformations = [t() for t in transformations]
+        self.rx_transformations = list(reversed(self.tx_transformations))
+
+    def set_rx_encoding(self, encoding, errors='replace'):
+        """set encoding for received data"""
+        self.input_encoding = encoding
+        self.rx_decoder = codecs.getincrementaldecoder(encoding)(errors)
+
+    def set_tx_encoding(self, encoding, errors='replace'):
+        """set encoding for transmitted data"""
+        self.output_encoding = encoding
+        self.tx_encoder = codecs.getincrementalencoder(encoding)(errors)
+
+    def dump_port_settings(self):
+        """Write current settings to sys.stderr"""
+        sys.stderr.write("\n--- Settings: {p.name}  {p.baudrate},{p.bytesize},{p.parity},{p.stopbits}\n".format(
+            p=self.serial))
+        sys.stderr.write('--- RTS: {:8}  DTR: {:8}  BREAK: {:8}\n'.format(
+            ('active' if self.serial.rts else 'inactive'),
+            ('active' if self.serial.dtr else 'inactive'),
+            ('active' if self.serial.break_condition else 'inactive')))
+        try:
+            sys.stderr.write('--- CTS: {:8}  DSR: {:8}  RI: {:8}  CD: {:8}\n'.format(
+                ('active' if self.serial.cts else 'inactive'),
+                ('active' if self.serial.dsr else 'inactive'),
+                ('active' if self.serial.ri else 'inactive'),
+                ('active' if self.serial.cd else 'inactive')))
+        except serial.SerialException:
+            # on RFC 2217 ports, it can happen if no modem state notification was
+            # yet received. ignore this error.
+            pass
+        sys.stderr.write('--- software flow control: {}\n'.format('active' if self.serial.xonxoff else 'inactive'))
+        sys.stderr.write('--- hardware flow control: {}\n'.format('active' if self.serial.rtscts else 'inactive'))
+        sys.stderr.write('--- serial input encoding: {}\n'.format(self.input_encoding))
+        sys.stderr.write('--- serial output encoding: {}\n'.format(self.output_encoding))
+        sys.stderr.write('--- EOL: {}\n'.format(self.eol.upper()))
+        sys.stderr.write('--- filters: {}\n'.format(' '.join(self.filters)))
+
+    def reader(self):
+        """loop and copy serial->console"""
+        try:
+            while self.alive and self._reader_alive:
+                # read all that is there or wait for one byte
+                data = self.serial.read(self.serial.in_waiting or 1)
+                if data:
+                    if self.raw:
+                        self.console.write_bytes(data)
+                    else:
+                        text = self.rx_decoder.decode(data)
+                        for transformation in self.rx_transformations:
+                            text = transformation.rx(text)
+                        self.console.write(text)
+        except serial.SerialException:
+            self.alive = False
+            self.console.cancel()
+            raise       # XXX handle instead of re-raise?
+
+    def writer(self):
+        """\
+        Loop and copy console->serial until self.exit_character character is
+        found. When self.menu_character is found, interpret the next key
+        locally.
+        """
+        menu_active = False
+        try:
+            while self.alive:
+                try:
+                    c = self.console.getkey()
+                except KeyboardInterrupt:
+                    c = '\x03'
+                if not self.alive:
+                    break
+                if menu_active:
+                    self.handle_menu_key(c)
+                    menu_active = False
+                elif c == self.menu_character:
+                    menu_active = True      # next char will be for menu
+                elif c == self.exit_character:
+                    self.stop()             # exit app
+                    break
+                else:
+                    #~ if self.raw:
+                    text = c
+                    for transformation in self.tx_transformations:
+                        text = transformation.tx(text)
+                    self.serial.write(self.tx_encoder.encode(text))
+                    if self.echo:
+                        echo_text = c
+                        for transformation in self.tx_transformations:
+                            echo_text = transformation.echo(echo_text)
+                        self.console.write(echo_text)
+        except:
+            self.alive = False
+            raise
+
+    def handle_menu_key(self, c):
+        """Implement a simple menu / settings"""
+        if c == self.menu_character or c == self.exit_character:
+            # Menu/exit character again -> send itself
+            self.serial.write(self.tx_encoder.encode(c))
+            if self.echo:
+                self.console.write(c)
+        elif c == '\x15':                       # CTRL+U -> upload file
+            self.upload_file()
+        elif c in '\x08hH?':                    # CTRL+H, h, H, ? -> Show help
+            sys.stderr.write(self.get_help_text())
+        elif c == '\x12':                       # CTRL+R -> Toggle RTS
+            self.serial.rts = not self.serial.rts
+            sys.stderr.write('--- RTS {} ---\n'.format('active' if self.serial.rts else 'inactive'))
+        elif c == '\x04':                       # CTRL+D -> Toggle DTR
+            self.serial.dtr = not self.serial.dtr
+            sys.stderr.write('--- DTR {} ---\n'.format('active' if self.serial.dtr else 'inactive'))
+        elif c == '\x02':                       # CTRL+B -> toggle BREAK condition
+            self.serial.break_condition = not self.serial.break_condition
+            sys.stderr.write('--- BREAK {} ---\n'.format('active' if self.serial.break_condition else 'inactive'))
+        elif c == '\x05':                       # CTRL+E -> toggle local echo
+            self.echo = not self.echo
+            sys.stderr.write('--- local echo {} ---\n'.format('active' if self.echo else 'inactive'))
+        elif c == '\x06':                       # CTRL+F -> edit filters
+            self.change_filter()
+        elif c == '\x0c':                       # CTRL+L -> EOL mode
+            modes = list(EOL_TRANSFORMATIONS)   # keys
+            eol = modes.index(self.eol) + 1
+            if eol >= len(modes):
+                eol = 0
+            self.eol = modes[eol]
+            sys.stderr.write('--- EOL: {} ---\n'.format(self.eol.upper()))
+            self.update_transformations()
+        elif c == '\x01':                       # CTRL+A -> set encoding
+            self.change_encoding()
+        elif c == '\x09':                       # CTRL+I -> info
+            self.dump_port_settings()
+        #~ elif c == '\x01':                       # CTRL+A -> cycle escape mode
+        #~ elif c == '\x0c':                       # CTRL+L -> cycle linefeed mode
+        elif c in 'pP':                         # P -> change port
+            self.change_port()
+        elif c in 'zZ':                         # S -> suspend / open port temporarily
+            self.suspend_port()
+        elif c in 'bB':                         # B -> change baudrate
+            self.change_baudrate()
+        elif c == '8':                          # 8 -> change to 8 bits
+            self.serial.bytesize = serial.EIGHTBITS
+            self.dump_port_settings()
+        elif c == '7':                          # 7 -> change to 8 bits
+            self.serial.bytesize = serial.SEVENBITS
+            self.dump_port_settings()
+        elif c in 'eE':                         # E -> change to even parity
+            self.serial.parity = serial.PARITY_EVEN
+            self.dump_port_settings()
+        elif c in 'oO':                         # O -> change to odd parity
+            self.serial.parity = serial.PARITY_ODD
+            self.dump_port_settings()
+        elif c in 'mM':                         # M -> change to mark parity
+            self.serial.parity = serial.PARITY_MARK
+            self.dump_port_settings()
+        elif c in 'sS':                         # S -> change to space parity
+            self.serial.parity = serial.PARITY_SPACE
+            self.dump_port_settings()
+        elif c in 'nN':                         # N -> change to no parity
+            self.serial.parity = serial.PARITY_NONE
+            self.dump_port_settings()
+        elif c == '1':                          # 1 -> change to 1 stop bits
+            self.serial.stopbits = serial.STOPBITS_ONE
+            self.dump_port_settings()
+        elif c == '2':                          # 2 -> change to 2 stop bits
+            self.serial.stopbits = serial.STOPBITS_TWO
+            self.dump_port_settings()
+        elif c == '3':                          # 3 -> change to 1.5 stop bits
+            self.serial.stopbits = serial.STOPBITS_ONE_POINT_FIVE
+            self.dump_port_settings()
+        elif c in 'xX':                         # X -> change software flow control
+            self.serial.xonxoff = (c == 'X')
+            self.dump_port_settings()
+        elif c in 'rR':                         # R -> change hardware flow control
+            self.serial.rtscts = (c == 'R')
+            self.dump_port_settings()
+        elif c in 'qQ':
+            self.stop()                         # Q -> exit app
+        else:
+            sys.stderr.write('--- unknown menu character {} --\n'.format(key_description(c)))
+
+    def upload_file(self):
+        """Ask user for filenname and send its contents"""
+        sys.stderr.write('\n--- File to upload: ')
+        sys.stderr.flush()
+        with self.console:
+            filename = sys.stdin.readline().rstrip('\r\n')
+            if filename:
+                try:
+                    with open(filename, 'rb') as f:
+                        sys.stderr.write('--- Sending file {} ---\n'.format(filename))
+                        while True:
+                            block = f.read(1024)
+                            if not block:
+                                break
+                            self.serial.write(block)
+                            # Wait for output buffer to drain.
+                            self.serial.flush()
+                            sys.stderr.write('.')   # Progress indicator.
+                    sys.stderr.write('\n--- File {} sent ---\n'.format(filename))
+                except IOError as e:
+                    sys.stderr.write('--- ERROR opening file {}: {} ---\n'.format(filename, e))
+
+    def change_filter(self):
+        """change the i/o transformations"""
+        sys.stderr.write('\n--- Available Filters:\n')
+        sys.stderr.write('\n'.join(
+            '---   {:<10} = {.__doc__}'.format(k, v)
+            for k, v in sorted(TRANSFORMATIONS.items())))
+        sys.stderr.write('\n--- Enter new filter name(s) [{}]: '.format(' '.join(self.filters)))
+        with self.console:
+            new_filters = sys.stdin.readline().lower().split()
+        if new_filters:
+            for f in new_filters:
+                if f not in TRANSFORMATIONS:
+                    sys.stderr.write('--- unknown filter: {!r}\n'.format(f))
+                    break
+            else:
+                self.filters = new_filters
+                self.update_transformations()
+        sys.stderr.write('--- filters: {}\n'.format(' '.join(self.filters)))
+
+    def change_encoding(self):
+        """change encoding on the serial port"""
+        sys.stderr.write('\n--- Enter new encoding name [{}]: '.format(self.input_encoding))
+        with self.console:
+            new_encoding = sys.stdin.readline().strip()
+        if new_encoding:
+            try:
+                codecs.lookup(new_encoding)
+            except LookupError:
+                sys.stderr.write('--- invalid encoding name: {}\n'.format(new_encoding))
+            else:
+                self.set_rx_encoding(new_encoding)
+                self.set_tx_encoding(new_encoding)
+        sys.stderr.write('--- serial input encoding: {}\n'.format(self.input_encoding))
+        sys.stderr.write('--- serial output encoding: {}\n'.format(self.output_encoding))
+
+    def change_baudrate(self):
+        """change the baudrate"""
+        sys.stderr.write('\n--- Baudrate: ')
+        sys.stderr.flush()
+        with self.console:
+            backup = self.serial.baudrate
+            try:
+                self.serial.baudrate = int(sys.stdin.readline().strip())
+            except ValueError as e:
+                sys.stderr.write('--- ERROR setting baudrate: {} ---\n'.format(e))
+                self.serial.baudrate = backup
+            else:
+                self.dump_port_settings()
+
+    def change_port(self):
+        """Have a conversation with the user to change the serial port"""
+        with self.console:
+            try:
+                port = ask_for_port()
+            except KeyboardInterrupt:
+                port = None
+        if port and port != self.serial.port:
+            # reader thread needs to be shut down
+            self._stop_reader()
+            # save settings
+            settings = self.serial.getSettingsDict()
+            try:
+                new_serial = serial.serial_for_url(port, do_not_open=True)
+                # restore settings and open
+                new_serial.applySettingsDict(settings)
+                new_serial.rts = self.serial.rts
+                new_serial.dtr = self.serial.dtr
+                new_serial.open()
+                new_serial.break_condition = self.serial.break_condition
+            except Exception as e:
+                sys.stderr.write('--- ERROR opening new port: {} ---\n'.format(e))
+                new_serial.close()
+            else:
+                self.serial.close()
+                self.serial = new_serial
+                sys.stderr.write('--- Port changed to: {} ---\n'.format(self.serial.port))
+            # and restart the reader thread
+            self._start_reader()
+
+    def suspend_port(self):
+        """\
+        open port temporarily, allow reconnect, exit and port change to get
+        out of the loop
+        """
+        # reader thread needs to be shut down
+        self._stop_reader()
+        self.serial.close()
+        sys.stderr.write('\n--- Port closed: {} ---\n'.format(self.serial.port))
+        do_change_port = False
+        while not self.serial.is_open:
+            sys.stderr.write('--- Quit: {exit} | p: port change | any other key to reconnect ---\n'.format(
+                exit=key_description(self.exit_character)))
+            k = self.console.getkey()
+            if k == self.exit_character:
+                self.stop()             # exit app
+                break
+            elif k in 'pP':
+                do_change_port = True
+                break
+            try:
+                self.serial.open()
+            except Exception as e:
+                sys.stderr.write('--- ERROR opening port: {} ---\n'.format(e))
+        if do_change_port:
+            self.change_port()
+        else:
+            # and restart the reader thread
+            self._start_reader()
+            sys.stderr.write('--- Port opened: {} ---\n'.format(self.serial.port))
+
+    def get_help_text(self):
+        """return the help text"""
+        # help text, starts with blank line!
+        return """
+--- pySerial ({version}) - miniterm - help
+---
+--- {exit:8} Exit program (alias {menu} Q)
+--- {menu:8} Menu escape key, followed by:
+--- Menu keys:
+---    {menu:7} Send the menu character itself to remote
+---    {exit:7} Send the exit character itself to remote
+---    {info:7} Show info
+---    {upload:7} Upload file (prompt will be shown)
+---    {repr:7} encoding
+---    {filter:7} edit filters
+--- Toggles:
+---    {rts:7} RTS   {dtr:7} DTR   {brk:7} BREAK
+---    {echo:7} echo  {eol:7} EOL
+---
+--- Port settings ({menu} followed by the following):
+---    p          change port
+---    7 8        set data bits
+---    N E O S M  change parity (None, Even, Odd, Space, Mark)
+---    1 2 3      set stop bits (1, 2, 1.5)
+---    b          change baud rate
+---    x X        disable/enable software flow control
+---    r R        disable/enable hardware flow control
+""".format(version=getattr(serial, 'VERSION', 'unknown version'),
+           exit=key_description(self.exit_character),
+           menu=key_description(self.menu_character),
+           rts=key_description('\x12'),
+           dtr=key_description('\x04'),
+           brk=key_description('\x02'),
+           echo=key_description('\x05'),
+           info=key_description('\x09'),
+           upload=key_description('\x15'),
+           repr=key_description('\x01'),
+           filter=key_description('\x06'),
+           eol=key_description('\x0c'))
+
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# default args can be used to override when calling main() from an other script
+# e.g to create a miniterm-my-device.py
+def main(default_port=None, default_baudrate=9600, default_rts=None, default_dtr=None):
+    """Command line tool, entry point"""
+
+    import argparse
+
+    parser = argparse.ArgumentParser(
+        description='Miniterm - A simple terminal program for the serial port.')
+
+    parser.add_argument(
+        'port',
+        nargs='?',
+        help='serial port name ("-" to show port list)',
+        default=default_port)
+
+    parser.add_argument(
+        'baudrate',
+        nargs='?',
+        type=int,
+        help='set baud rate, default: %(default)s',
+        default=default_baudrate)
+
+    group = parser.add_argument_group('port settings')
+
+    group.add_argument(
+        '--parity',
+        choices=['N', 'E', 'O', 'S', 'M'],
+        type=lambda c: c.upper(),
+        help='set parity, one of {N E O S M}, default: N',
+        default='N')
+
+    group.add_argument(
+        '--rtscts',
+        action='store_true',
+        help='enable RTS/CTS flow control (default off)',
+        default=False)
+
+    group.add_argument(
+        '--xonxoff',
+        action='store_true',
+        help='enable software flow control (default off)',
+        default=False)
+
+    group.add_argument(
+        '--rts',
+        type=int,
+        help='set initial RTS line state (possible values: 0, 1)',
+        default=default_rts)
+
+    group.add_argument(
+        '--dtr',
+        type=int,
+        help='set initial DTR line state (possible values: 0, 1)',
+        default=default_dtr)
+
+    group.add_argument(
+        '--non-exclusive',
+        dest='exclusive',
+        action='store_false',
+        help='disable locking for native ports',
+        default=True)
+
+    group.add_argument(
+        '--ask',
+        action='store_true',
+        help='ask again for port when open fails',
+        default=False)
+
+    group = parser.add_argument_group('data handling')
+
+    group.add_argument(
+        '-e', '--echo',
+        action='store_true',
+        help='enable local echo (default off)',
+        default=False)
+
+    group.add_argument(
+        '--encoding',
+        dest='serial_port_encoding',
+        metavar='CODEC',
+        help='set the encoding for the serial port (e.g. hexlify, Latin1, UTF-8), default: %(default)s',
+        default='UTF-8')
+
+    group.add_argument(
+        '-f', '--filter',
+        action='append',
+        metavar='NAME',
+        help='add text transformation',
+        default=[])
+
+    group.add_argument(
+        '--eol',
+        choices=['CR', 'LF', 'CRLF'],
+        type=lambda c: c.upper(),
+        help='end of line mode',
+        default='CRLF')
+
+    group.add_argument(
+        '--raw',
+        action='store_true',
+        help='Do no apply any encodings/transformations',
+        default=False)
+
+    group = parser.add_argument_group('hotkeys')
+
+    group.add_argument(
+        '--exit-char',
+        type=int,
+        metavar='NUM',
+        help='Unicode of special character that is used to exit the application, default: %(default)s',
+        default=0x1d)  # GS/CTRL+]
+
+    group.add_argument(
+        '--menu-char',
+        type=int,
+        metavar='NUM',
+        help='Unicode code of special character that is used to control miniterm (menu), default: %(default)s',
+        default=0x14)  # Menu: CTRL+T
+
+    group = parser.add_argument_group('diagnostics')
+
+    group.add_argument(
+        '-q', '--quiet',
+        action='store_true',
+        help='suppress non-error messages',
+        default=False)
+
+    group.add_argument(
+        '--develop',
+        action='store_true',
+        help='show Python traceback on error',
+        default=False)
+
+    args = parser.parse_args()
+
+    if args.menu_char == args.exit_char:
+        parser.error('--exit-char can not be the same as --menu-char')
+
+    if args.filter:
+        if 'help' in args.filter:
+            sys.stderr.write('Available filters:\n')
+            sys.stderr.write('\n'.join(
+                '{:<10} = {.__doc__}'.format(k, v)
+                for k, v in sorted(TRANSFORMATIONS.items())))
+            sys.stderr.write('\n')
+            sys.exit(1)
+        filters = args.filter
+    else:
+        filters = ['default']
+
+    while True:
+        # no port given on command line -> ask user now
+        if args.port is None or args.port == '-':
+            try:
+                args.port = ask_for_port()
+            except KeyboardInterrupt:
+                sys.stderr.write('\n')
+                parser.error('user aborted and port is not given')
+            else:
+                if not args.port:
+                    parser.error('port is not given')
+        try:
+            serial_instance = serial.serial_for_url(
+                args.port,
+                args.baudrate,
+                parity=args.parity,
+                rtscts=args.rtscts,
+                xonxoff=args.xonxoff,
+                do_not_open=True)
+
+            if not hasattr(serial_instance, 'cancel_read'):
+                # enable timeout for alive flag polling if cancel_read is not available
+                serial_instance.timeout = 1
+
+            if args.dtr is not None:
+                if not args.quiet:
+                    sys.stderr.write('--- forcing DTR {}\n'.format('active' if args.dtr else 'inactive'))
+                serial_instance.dtr = args.dtr
+            if args.rts is not None:
+                if not args.quiet:
+                    sys.stderr.write('--- forcing RTS {}\n'.format('active' if args.rts else 'inactive'))
+                serial_instance.rts = args.rts
+
+            if isinstance(serial_instance, serial.Serial):
+                serial_instance.exclusive = args.exclusive
+
+            serial_instance.open()
+        except serial.SerialException as e:
+            sys.stderr.write('could not open port {!r}: {}\n'.format(args.port, e))
+            if args.develop:
+                raise
+            if not args.ask:
+                sys.exit(1)
+            else:
+                args.port = '-'
+        else:
+            break
+
+    miniterm = Miniterm(
+        serial_instance,
+        echo=args.echo,
+        eol=args.eol.lower(),
+        filters=filters)
+    miniterm.exit_character = unichr(args.exit_char)
+    miniterm.menu_character = unichr(args.menu_char)
+    miniterm.raw = args.raw
+    miniterm.set_rx_encoding(args.serial_port_encoding)
+    miniterm.set_tx_encoding(args.serial_port_encoding)
+
+    if not args.quiet:
+        sys.stderr.write('--- Miniterm on {p.name}  {p.baudrate},{p.bytesize},{p.parity},{p.stopbits} ---\n'.format(
+            p=miniterm.serial))
+        sys.stderr.write('--- Quit: {} | Menu: {} | Help: {} followed by {} ---\n'.format(
+            key_description(miniterm.exit_character),
+            key_description(miniterm.menu_character),
+            key_description(miniterm.menu_character),
+            key_description('\x08')))
+
+    miniterm.start()
+    try:
+        miniterm.join(True)
+    except KeyboardInterrupt:
+        pass
+    if not args.quiet:
+        sys.stderr.write('\n--- exit ---\n')
+    miniterm.join()
+    miniterm.close()
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+if __name__ == '__main__':
+    main()
diff --git a/modbus/serial/urlhandler/__init__.py b/modbus/serial/urlhandler/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modbus/serial/urlhandler/protocol_alt.py b/modbus/serial/urlhandler/protocol_alt.py
new file mode 100644
index 00000000..2e666ca7
--- /dev/null
+++ b/modbus/serial/urlhandler/protocol_alt.py
@@ -0,0 +1,57 @@
+#! python
+#
+# This module implements a special URL handler that allows selecting an
+# alternate implementation provided by some backends.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+#
+# URL format:    alt://port[?option[=value][&option[=value]]]
+# options:
+# - class=X used class named X instead of Serial
+#
+# example:
+#   use poll based implementation on Posix (Linux):
+#   python -m serial.tools.miniterm alt:///dev/ttyUSB0?class=PosixPollSerial
+
+from __future__ import absolute_import
+
+try:
+    import urlparse
+except ImportError:
+    import urllib.parse as urlparse
+
+import serial
+
+
+def serial_class_for_url(url):
+    """extract host and port from an URL string"""
+    parts = urlparse.urlsplit(url)
+    if parts.scheme != 'alt':
+        raise serial.SerialException(
+            'expected a string in the form "alt://port[?option[=value][&option[=value]]]": '
+            'not starting with alt:// ({!r})'.format(parts.scheme))
+    class_name = 'Serial'
+    try:
+        for option, values in urlparse.parse_qs(parts.query, True).items():
+            if option == 'class':
+                class_name = values[0]
+            else:
+                raise ValueError('unknown option: {!r}'.format(option))
+    except ValueError as e:
+        raise serial.SerialException(
+            'expected a string in the form '
+            '"alt://port[?option[=value][&option[=value]]]": {!r}'.format(e))
+    if not hasattr(serial, class_name):
+        raise ValueError('unknown class: {!r}'.format(class_name))
+    cls = getattr(serial, class_name)
+    if not issubclass(cls, serial.Serial):
+        raise ValueError('class {!r} is not an instance of Serial'.format(class_name))
+    return (''.join([parts.netloc, parts.path]), cls)
+
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+if __name__ == '__main__':
+    s = serial.serial_for_url('alt:///dev/ttyS0?class=PosixPollSerial')
+    print(s)
diff --git a/modbus/serial/urlhandler/protocol_cp2110.py b/modbus/serial/urlhandler/protocol_cp2110.py
new file mode 100644
index 00000000..44ad4eb4
--- /dev/null
+++ b/modbus/serial/urlhandler/protocol_cp2110.py
@@ -0,0 +1,258 @@
+#! python
+#
+# Backend for Silicon Labs CP2110/4 HID-to-UART devices.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2001-2015 Chris Liechti 
+# (C) 2019 Google LLC
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+
+# This backend implements support for HID-to-UART devices manufactured
+# by Silicon Labs and marketed as CP2110 and CP2114. The
+# implementation is (mostly) OS-independent and in userland. It relies
+# on cython-hidapi (https://github.com/trezor/cython-hidapi).
+
+# The HID-to-UART protocol implemented by CP2110/4 is described in the
+# AN434 document from Silicon Labs:
+# https://www.silabs.com/documents/public/application-notes/AN434-CP2110-4-Interface-Specification.pdf
+
+# TODO items:
+
+# - rtscts support is configured for hardware flow control, but the
+#   signaling is missing (AN434 suggests this is done through GPIO).
+# - Cancelling reads and writes is not supported.
+# - Baudrate validation is not implemented, as it depends on model and configuration.
+
+import struct
+import threading
+
+try:
+    import urlparse
+except ImportError:
+    import urllib.parse as urlparse
+
+try:
+    import Queue
+except ImportError:
+    import queue as Queue
+
+import hid  # hidapi
+
+import serial
+from serial.serialutil import SerialBase, SerialException, PortNotOpenError, to_bytes, Timeout
+
+
+# Report IDs and related constant
+_REPORT_GETSET_UART_ENABLE = 0x41
+_DISABLE_UART = 0x00
+_ENABLE_UART = 0x01
+
+_REPORT_SET_PURGE_FIFOS = 0x43
+_PURGE_TX_FIFO = 0x01
+_PURGE_RX_FIFO = 0x02
+
+_REPORT_GETSET_UART_CONFIG = 0x50
+
+_REPORT_SET_TRANSMIT_LINE_BREAK = 0x51
+_REPORT_SET_STOP_LINE_BREAK = 0x52
+
+
+class Serial(SerialBase):
+    # This is not quite correct. AN343 specifies that the minimum
+    # baudrate is different between CP2110 and CP2114, and it's halved
+    # when using non-8-bit symbols.
+    BAUDRATES = (300, 375, 600, 1200, 1800, 2400, 4800, 9600, 19200,
+                 38400, 57600, 115200, 230400, 460800, 500000, 576000,
+                 921600, 1000000)
+
+    def __init__(self, *args, **kwargs):
+        self._hid_handle = None
+        self._read_buffer = None
+        self._thread = None
+        super(Serial, self).__init__(*args, **kwargs)
+
+    def open(self):
+        if self._port is None:
+            raise SerialException("Port must be configured before it can be used.")
+        if self.is_open:
+            raise SerialException("Port is already open.")
+
+        self._read_buffer = Queue.Queue()
+
+        self._hid_handle = hid.device()
+        try:
+            portpath = self.from_url(self.portstr)
+            self._hid_handle.open_path(portpath)
+        except OSError as msg:
+            raise SerialException(msg.errno, "could not open port {}: {}".format(self._port, msg))
+
+        try:
+            self._reconfigure_port()
+        except:
+            try:
+                self._hid_handle.close()
+            except:
+                pass
+            self._hid_handle = None
+            raise
+        else:
+            self.is_open = True
+            self._thread = threading.Thread(target=self._hid_read_loop)
+            self._thread.setDaemon(True)
+            self._thread.setName('pySerial CP2110 reader thread for {}'.format(self._port))
+            self._thread.start()
+
+    def from_url(self, url):
+        parts = urlparse.urlsplit(url)
+        if parts.scheme != "cp2110":
+            raise SerialException(
+                'expected a string in the forms '
+                '"cp2110:///dev/hidraw9" or "cp2110://0001:0023:00": '
+                'not starting with cp2110:// {{!r}}'.format(parts.scheme))
+        if parts.netloc:  # cp2100://BUS:DEVICE:ENDPOINT, for libusb
+            return parts.netloc.encode('utf-8')
+        return parts.path.encode('utf-8')
+
+    def close(self):
+        self.is_open = False
+        if self._thread:
+            self._thread.join(1)  # read timeout is 0.1
+            self._thread = None
+        self._hid_handle.close()
+        self._hid_handle = None
+
+    def _reconfigure_port(self):
+        parity_value = None
+        if self._parity == serial.PARITY_NONE:
+            parity_value = 0x00
+        elif self._parity == serial.PARITY_ODD:
+            parity_value = 0x01
+        elif self._parity == serial.PARITY_EVEN:
+            parity_value = 0x02
+        elif self._parity == serial.PARITY_MARK:
+            parity_value = 0x03
+        elif self._parity == serial.PARITY_SPACE:
+            parity_value = 0x04
+        else:
+            raise ValueError('Invalid parity: {!r}'.format(self._parity))
+
+        if self.rtscts:
+            flow_control_value = 0x01
+        else:
+            flow_control_value = 0x00
+
+        data_bits_value = None
+        if self._bytesize == 5:
+            data_bits_value = 0x00
+        elif self._bytesize == 6:
+            data_bits_value = 0x01
+        elif self._bytesize == 7:
+            data_bits_value = 0x02
+        elif self._bytesize == 8:
+            data_bits_value = 0x03
+        else:
+            raise ValueError('Invalid char len: {!r}'.format(self._bytesize))
+
+        stop_bits_value = None
+        if self._stopbits == serial.STOPBITS_ONE:
+            stop_bits_value = 0x00
+        elif self._stopbits == serial.STOPBITS_ONE_POINT_FIVE:
+            stop_bits_value = 0x01
+        elif self._stopbits == serial.STOPBITS_TWO:
+            stop_bits_value = 0x01
+        else:
+            raise ValueError('Invalid stop bit specification: {!r}'.format(self._stopbits))
+
+        configuration_report = struct.pack(
+            '>BLBBBB',
+            _REPORT_GETSET_UART_CONFIG,
+            self._baudrate,
+            parity_value,
+            flow_control_value,
+            data_bits_value,
+            stop_bits_value)
+
+        self._hid_handle.send_feature_report(configuration_report)
+
+        self._hid_handle.send_feature_report(
+            bytes((_REPORT_GETSET_UART_ENABLE, _ENABLE_UART)))
+        self._update_break_state()
+
+    @property
+    def in_waiting(self):
+        return self._read_buffer.qsize()
+
+    def reset_input_buffer(self):
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._hid_handle.send_feature_report(
+            bytes((_REPORT_SET_PURGE_FIFOS, _PURGE_RX_FIFO)))
+        # empty read buffer
+        while self._read_buffer.qsize():
+            self._read_buffer.get(False)
+
+    def reset_output_buffer(self):
+        if not self.is_open:
+            raise PortNotOpenError()
+        self._hid_handle.send_feature_report(
+            bytes((_REPORT_SET_PURGE_FIFOS, _PURGE_TX_FIFO)))
+
+    def _update_break_state(self):
+        if not self._hid_handle:
+            raise PortNotOpenError()
+
+        if self._break_state:
+            self._hid_handle.send_feature_report(
+                bytes((_REPORT_SET_TRANSMIT_LINE_BREAK, 0)))
+        else:
+            # Note that while AN434 states "There are no data bytes in
+            # the payload other than the Report ID", either hidapi or
+            # Linux does not seem to send the report otherwise.
+            self._hid_handle.send_feature_report(
+                bytes((_REPORT_SET_STOP_LINE_BREAK, 0)))
+
+    def read(self, size=1):
+        if not self.is_open:
+            raise PortNotOpenError()
+
+        data = bytearray()
+        try:
+            timeout = Timeout(self._timeout)
+            while len(data) < size:
+                if self._thread is None:
+                    raise SerialException('connection failed (reader thread died)')
+                buf = self._read_buffer.get(True, timeout.time_left())
+                if buf is None:
+                    return bytes(data)
+                data += buf
+                if timeout.expired():
+                    break
+        except Queue.Empty:  # -> timeout
+            pass
+        return bytes(data)
+
+    def write(self, data):
+        if not self.is_open:
+            raise PortNotOpenError()
+        data = to_bytes(data)
+        tx_len = len(data)
+        while tx_len > 0:
+            to_be_sent = min(tx_len, 0x3F)
+            report = to_bytes([to_be_sent]) + data[:to_be_sent]
+            self._hid_handle.write(report)
+
+            data = data[to_be_sent:]
+            tx_len = len(data)
+
+    def _hid_read_loop(self):
+        try:
+            while self.is_open:
+                data = self._hid_handle.read(64, timeout_ms=100)
+                if not data:
+                    continue
+                data_len = data.pop(0)
+                assert data_len == len(data)
+                self._read_buffer.put(bytearray(data))
+        finally:
+            self._thread = None
diff --git a/modbus/serial/urlhandler/protocol_hwgrep.py b/modbus/serial/urlhandler/protocol_hwgrep.py
new file mode 100644
index 00000000..1a288c94
--- /dev/null
+++ b/modbus/serial/urlhandler/protocol_hwgrep.py
@@ -0,0 +1,91 @@
+#! python
+#
+# This module implements a special URL handler that uses the port listing to
+# find ports by searching the string descriptions.
+#
+# This file is part of pySerial. https://github.com/pyserial/pyserial
+# (C) 2011-2015 Chris Liechti 
+#
+# SPDX-License-Identifier:    BSD-3-Clause
+#
+# URL format:    hwgrep://&