Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# modbus-cli

```
_ _ _
_ __ ___ __| |__ _ _ ___ __ _| (_)
| ' \/ _ \/ _` / _` || (_-< / _| | | |
|_|_|_\___/\__,_\__,_|\_/__/ \__|_|_|_|
__ __ ___ ___ ___ _ _ ___ ___ _ ___
| \/ |/ _ \| \| _ )| | | / __| ___ / __| | |_ _|
| |\/| | (_) | |) | _ \| |_| \__ \ |___| (__| |__ | |
|_| |_|\___/|___/|___/ \___/|___/ \___|____|___|
```

**Like curl, but for Modbus.**
Expand Down Expand Up @@ -87,6 +87,12 @@ modbus read --serial /dev/ttyUSB0 40001 --slave 2 --baudrate 19200

# Signed 16-bit values
modbus read 192.168.1.10 40001 -c 5 -f signed

# Decode as float32 values (2 registers per float)
modbus read 192.168.1.10 40001 -c 4 --float

# Adjust byte/word ordering for vendor-specific layouts
modbus read 192.168.1.10 40001 -c 4 --float --byte-order BE --word-order LE
```

Output includes styled panels, connection status, and visual value bars showing register magnitude at a glance.
Expand Down
76 changes: 75 additions & 1 deletion modbus_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import sys
import time
import struct

import click
from pymodbus.client import ModbusTcpClient, ModbusSerialClient
Expand Down Expand Up @@ -86,6 +87,27 @@ def _format_value(value, fmt):
return str(value)


def _decode_float32_pair(words, byte_order="BE", word_order="BE"):
"""Decode 2x16-bit Modbus words into one IEEE-754 float32 value."""
if len(words) != 2:
raise ValueError("Float decoding requires exactly two 16-bit words")

ordered_words = list(words)
if word_order == "LE":
ordered_words.reverse()

data = bytearray()
for word in ordered_words:
hi = (word >> 8) & 0xFF
lo = word & 0xFF
if byte_order == "BE":
data.extend((hi, lo))
else:
data.extend((lo, hi))

return struct.unpack(">f", bytes(data))[0]


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -130,8 +152,16 @@ def cli(ctx):
@click.option("--format", "-f", "fmt",
type=click.Choice(["decimal", "hex", "bin", "signed"]),
default="decimal", help="Output format (default: decimal).")
@click.option("--float", "decode_float", is_flag=True,
help="Decode register pairs as 32-bit IEEE 754 floats.")
@click.option("--byte-order", type=click.Choice(["BE", "LE"]),
default="BE", show_default=True,
help="Byte order within each 16-bit register for --float mode.")
@click.option("--word-order", type=click.Choice(["BE", "LE"]),
Comment on lines +155 to +160
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With --float enabled, the --format/-f option is still accepted but isn’t used (float output is always formatted via decoded:.6g). This can be confusing for users; consider rejecting --format when --float is set, or clarifying in help text that --format applies only to integer register output (or introducing a dedicated float formatting option).

Copilot uses AI. Check for mistakes.
default="BE", show_default=True,
help="Word order across each 32-bit float pair for --float mode.")
@click.option("--timeout", default=3.0, help="Timeout in seconds (default: 3).")
def read(host, address, port, serial, baudrate, slave, count, reg_type, fmt, timeout):
def read(host, address, port, serial, baudrate, slave, count, reg_type, fmt, decode_float, byte_order, word_order, timeout):
"""Read Modbus registers.

\b
Expand Down Expand Up @@ -165,6 +195,50 @@ def read(host, address, port, serial, baudrate, slave, count, reg_type, fmt, tim
else:
values = resp.registers

if decode_float:
if detected_type in ("coil", "discrete"):
error_panel("--float is only supported for holding/input registers")
sys.exit(1)
if count % 2 != 0:
error_panel("--float requires an even --count (2 registers per float)")
sys.exit(1)

table = Table(
show_header=True,
header_style="bold #00d4aa",
border_style="#636e72",
title_style="bold #7c6ff7",
row_styles=["", "dim"],
pad_edge=True,
expand=False,
)
table.add_column("Address", style="bold #7c6ff7", justify="right", min_width=8)
table.add_column("Float", style="bold #00d4aa", justify="right", min_width=12)
table.add_column("Raw Words", style="#dfe6e9", justify="right", min_width=18)

for i in range(0, len(values), 2):
addr_display = address + i if not reg_type else raw_address + i
pair = [int(values[i]), int(values[i + 1])]
decoded = _decode_float32_pair(pair, byte_order=byte_order, word_order=word_order)
table.add_row(
str(addr_display),
f"{decoded:.6g}",
f"[{pair[0]}, {pair[1]}]",
)

console.print(Panel(
table,
border_style="#636e72",
title=f"[bold #00d4aa]{detected_type}[/] [dim]float32 decode[/]",
subtitle=(
f"[dim]{count} register(s), byte-order={byte_order}, "
f"word-order={word_order}, target={target}[/]"
),
padding=(1, 2),
))
console.print()
return

table = Table(
show_header=True,
header_style="bold #00d4aa",
Expand Down
8 changes: 4 additions & 4 deletions modbus_cli/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@

console = Console(theme=custom_theme)

BANNER = r"""[bold #00d4aa] _ _ _ [/]
[bold #1ad4b8] _ __ ___ __| |__ _ _ ___ __ _| (_)[/]
[bold #33d4c6] | ' \/ _ \/ _` / _` || (_-< / _| | | |[/]
[bold #4dd4d4] |_|_|_\___/\__,_\__,_|\_/__/ \__|_|_|_|[/]
BANNER = r"""[bold #00d4aa] __ __ ___ ___ ___ _ _ ___ ___ _ ___[/]
[bold #1ad4b8] | \/ |/ _ \| \| _ )| | | / __| ___ / __| | |_ _|[/]
[bold #33d4c6] | |\/| | (_) | |) | _ \| |_| \__ \ |___| (__| |__ | |[/]
[bold #4dd4d4] |_| |_|\___/|___/|___/ \___/|___/ \___|____|___|[/]
"""

TAGLINE = "[dim]like curl, but for modbus[/dim]"
Expand Down
Loading