modbus_lint

    General-purpose Modbus point-table linter: catches duplicate register names, out-of-range and overlapping addresses, oversized address gaps and conflicting JSONB field mappings.

    modbus
    linter
    mes
    plc
    cli
    validation
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    7 days ago
    Downloads
    6

    #modbus_lint

    A Modbus point-table linter written in MoonBit.

    It parses a plain-text register point table and reports common mistakes: duplicate register names, out-of-range addresses, registers spilling past the end of their Modbus area, address overlaps inside one area, malformed JSONB field names, and more (see the rules below). Devices and lint findings can also move in and out of JSON, making it a drop-in checker for toolchains that already model points as JSON.

    #Point table format

    One register per line; # starts a comment. The Modbus area is inferred from the address prefix (coils 1-9999, discrete inputs 10001-19999, input registers 30001-39999, holding registers 40001-49999).

    # name address type access unit jsonb coil_temp 40001 float32 R degC jsonb->coil_temp conveyor 40003 bool RW jsonb->conveyor_run tank_level 40004 uint16 R mm jsonb->tank_level

    Supported types: bool int16 uint16 int32 uint32 float32 float64. Access: R W RW.

    #Rules

    The lint(device) entry point (and lint_with_gap(device, threshold) for a custom address-gap threshold) reports Error/Warning findings:

    1. Duplicate register names.
    2. Address outside a standard Modbus area.
    3. A multi-word register spilling past the end of its area.
    4. Address overlap inside one area (word-aware).
    5. Malformed jsonb-> dotted path.
    6. Large address gap (threshold 16 words) suggesting a missing or mistyped entry.
    7. Multiple registers mapping to the same JSONB field.
    8. Name not in [A-Za-z0-9_.-] (empty name is an error).
    9. Multi-word type used in a bit-addressed area (coil / DI).
    10. Write access declared on an input-only area (DI / IR).
    11. Names differing only by letter case.

    #Usage

    let dev = @modbus_lint.parse_device(text)?
    let issues = @modbus_lint.lint(dev)
    println(@modbus_lint.render(issues))

    JSON round-trip, multiple report formats and point-table tools are all exposed:

    let s = @modbus_lint.device_to_json_string(dev) // Device -> pretty JSON
    let back = @modbus_lint.parse_device_json(s)? // JSON -> Device
    println(@modbus_lint.render_summary(dev, issues)) // errors/warnings + coverage
    let sorted = @modbus_lint.sort_by_address(dev) // tools.sort_by_address

    Or run the CLI:

    moon run cmd/main # built-in demo moon run cmd/main -- "a 40001 int16 R" # lint given register line moon run cmd/main -- --format summary --demo moon run cmd/main -- --json-in "$(cat points.json)" moon run cmd/main -- --gap 32 --show "coil_temp 40001 int16 R degC"

    Use --help for the full option list; --format accepts text|json|markdown|summary, --gap N overrides the gap threshold, and --show prints the point table sorted by address first.

    #Layout

    • modbus_lint.mbt — data model and pure helpers (pad, area_of, ...)
    • parser.mbt — text to Device
    • validator.mbt — lint rules
    • reporter.mbt — text / JSON / Markdown / by-area / summary reports
    • jsonio.mbt — Device & Issue JSON conversion
    • jsonb.mbtjsonb-> dotted-path parsing and validation
    • tools.mbt — sorting, dedupe, filtering, lookup, stats
    • cmd/main/main.mbt — CLI entry point

    Access

    pub enum Access {
    Read
    Write
    ReadWrite
    }

    Access mode of a register.

    AreaStats

    pub struct AreaStats {
    area : Int
    registers : Int
    words : Int
    free_words : Int
    utilization_permille : Int
    bytes : Int
    }

    Per-area summarised allocation.

    ByteOrder

    pub enum ByteOrder {
    BigEndian
    LittleEndian
    }

    How the two bytes of a 16-bit word are placed on the wire.

    Device

    pub struct Device {
    registers : Array[Register]
    }

    A parsed device: an ordered list of registers.

    DeviceStats

    pub struct DeviceStats {
    total_registers : Int
    total_words : Int
    total_bytes : Int
    bit_registers : Int
    jsonb_mapped : Int
    unitless : Int
    min_name_len : Int
    max_name_len : Int
    avg_name_len : Int
    areas : Array[AreaStats]
    }

    Whole-device stats snapshot.

    Issue

    pub struct Issue {
    severity : Severity
    line : Int
    message : String
    }

    A single lint finding.

    Register

    pub struct Register {
    name : String
    address : Int
    rtype : RegisterType
    access : Access
    unit : String
    jsonb : String
    line : Int
    }

    A single register described in a point table.

    RegisterType

    pub enum RegisterType {
    TBit
    TInt16
    TUInt16
    TInt32
    TUInt32
    TFloat32
    TFloat64
    }

    Register data types we understand. Word count follows the Modbus convention (1 word = 2 bytes = 16 bits).

    Severity

    pub enum Severity {
    Error
    Warning
    }

    Severity of a lint finding.

    WordOrder

    pub enum WordOrder {
    HighWordLowAddress
    LowWordLowAddress
    }

    In which register the high word of a 32-bit value is mapped.

    access_name

    fn access_name(access : Access) -> String

    Keyword for an access mode, symmetric with parse_access.

    area_bounds

    fn area_bounds(dev : Device, area : Int) -> (Int, Int)

    The 0-based local word bounds (lo, hi) that area occupies (inclusive); (-1, -1) when the area has no register.

    area_is_bit

    fn area_is_bit(area : Int) -> Bool

    True when the area is bit-addressable (coils / discrete inputs).

    area_is_input

    fn area_is_input(area : Int) -> Bool

    True when the area holds inputs only (discrete inputs / input registers).

    area_name

    fn area_name(area : Int) -> String

    Human-readable name of an area id (see area_of).

    byte_order_name

    fn byte_order_name(order : ByteOrder) -> String

    Human keyword for a byte order, for reports and (de)serialization.

    byte_order_of

    fn byte_order_of(name : String) -> ByteOrder

    Resolve a keyword ("big"/"little", case-insensitive) into a ByteOrder, defaulting to big-endian for anything unrecognised.

    bytes_to_uint16

    fn bytes_to_uint16(b0 : Int, b1 : Int, order : ByteOrder) -> Int

    Rebuild a 16-bit word from two bytes (ints in 0..=255) under order. The pair is (first_byte, second_byte) in wire order.

    compute_stats

    fn compute_stats(dev : Device) -> DeviceStats

    Build a full statistics snapshot for a Device.

    count_by_access

    fn count_by_access(dev : Device) -> Array[(String, Int)]

    Access modes used, with the count of each mode.

    count_by_area

    fn count_by_area(dev : Device) -> Array[(Int, Int)]

    Registers grouped by area id, with the count of each area.

    count_by_type

    fn count_by_type(dev : Device) -> Array[(String, Int)]

    Registers grouped by type keyword, with the count of each type.

    decode_coil

    fn decode_coil(word : Int) -> Bool

    Decode a coil bit from a 16-bit word; any non-zero value counts as on.

    decode_int16

    fn decode_int16(word : Int) -> Int

    Map an unsigned 16-bit word back to a signed int16 (two's complement).

    decode_int32_bytes

    fn decode_int32_bytes(bytes : Array[Int], byteOrder : ByteOrder, wordOrder : WordOrder) -> Int

    Decode a 4-byte Modbus payload back into a 32-bit int.

    decode_uint16

    fn decode_uint16(word : Int) -> Int

    Decode a uint16 value from a word (0..=65535).

    dedupe

    fn dedupe(dev : Device) -> Device

    Returns a new Device with exact duplicate registers removed. Two registers are duplicates when every field matches.

    device_to_json

    fn device_to_json(dev : Device) -> Json

    Serialize a whole Device to a Json value.

    device_to_json_string

    fn device_to_json_string(dev : Device) -> String

    Serialize a whole Device to a pretty JSON string.

    dominant_area

    fn dominant_area(dev : Device) -> (Int, Int)

    The area with the largest absolute word consumption. Returns (-1, 0) when the device is empty.

    encode_coil

    fn encode_coil(value : Bool) -> Int

    The unsigned 16-bit word for a coil bit (0 or 1).

    encode_int16

    fn encode_int16(v : Int) -> Int

    Map a signed int16 (-32768..=32767) onto its unsigned 16-bit word.

    encode_int32_bytes

    fn encode_int32_bytes(v : Int, byteOrder : ByteOrder, wordOrder : WordOrder) -> Array[Int]

    Encode a 32-bit value into a Modbus byte payload ([hi..lo] per word) given both byte and word order. Returns 4 bytes in wire order, ints in 0..=255.

    encode_uint16

    fn encode_uint16(v : Int) -> Int

    The unsigned 16-bit word for a uint16 value (0..=65535).

    error_count

    fn error_count(issues : Array[Issue]) -> Int

    Count findings with Error severity (used to decide a non-zero exit code).

    exact_address_collisions

    fn exact_address_collisions(dev : Device) -> Int

    Count how many pairs of registers share the exact same absolute address. (Overlap linting is stricter; this is a cheap exact-collision metric.)

    filter_area

    fn filter_area(dev : Device, area : Int) -> Device

    Returns a new Device containing only registers whose area equals area.

    first_free_address

    fn first_free_address(dev : Device, area : Int) -> Int

    The lowest absolute address in area that is not yet used by a register.

    float32_bytes

    fn float32_bytes(v : Double, byteOrder : ByteOrder) -> Array[Int]

    The four bytes (in wire order for byteOrder) of a single-precision value.

    float32_from_bytes

    fn float32_from_bytes(bytes : Array[Int], byteOrder : ByteOrder) -> Double

    Rebuild a single-precision value from four bytes.

    float32_from_words

    fn float32_from_words(hi : Int, lo : Int) -> Double

    Rebuild a single-precision value from its two words (high word first).

    float32_words

    fn float32_words(v : Double) -> (Int, Int)

    The two 16-bit words (high word first) of a single-precision value.

    float64_bytes

    fn float64_bytes(v : Double, byteOrder : ByteOrder) -> Array[Int]

    The eight bytes (in wire order for byteOrder) of a double value, walking four words from the most-significant word first.

    float64_from_bytes

    fn float64_from_bytes(bytes : Array[Int], byteOrder : ByteOrder) -> Double

    Rebuild a double value from eight bytes produced by float64_bytes.

    float64_from_words

    fn float64_from_words(w0 : Int, w1 : Int, w2 : Int, w3 : Int) -> Double

    Rebuild a double-precision value from its four words (high word first).

    float64_words

    fn float64_words(v : Double) -> (Int, Int, Int, Int)

    The four 16-bit words (high word first) of a double-precision value.

    free_word_count

    fn free_word_count(dev : Device, area : Int) -> Int

    How many 16-bit words in area are still unallocated.

    gap_bounds

    fn gap_bounds(dev : Device) -> (Int, Int)

    Smallest and largest address gaps (in words) between consecutive registers in the same area, computed from the whole device. (0, 0) when there are fewer than two registers in any single area.

    gap_pairs

    fn gap_pairs(dev : Device, threshold : Int) -> Array[(String, Int, Int)]

    For each register, record the gap (in words) to the next occupied register in the same area when that gap is strictly greater than threshold. Returns (name, address, gap_words) triples, e.g. useful to pre-check allocation density before running the linter.

    int16_bytes

    fn int16_bytes(v : Int, byteOrder : ByteOrder) -> Array[Int]

    The two bytes, in wire order for byteOrder, of a 16-bit value. This is the minimal Modbus payload a single register occupies on the bus.

    int16_from_bytes

    fn int16_from_bytes(bytes : Array[Int], byteOrder : ByteOrder) -> Int

    Rebuild a 16-bit word from a two-byte wire payload (ints in 0..=255).

    int32_fold

    fn int32_fold(hi : Int, lo : Int) -> Int

    Fold two unsigned 16-bit words back into a signed 32-bit int. Because Int is 32-bit two's complement, (hi << 16) | lo already yields the correct signed value for both positive and negative inputs.

    int32_fold_words

    fn int32_fold_words(w0 : Int, w1 : Int, order : WordOrder) -> Int

    Rebuild a 32-bit int from the two words as transmitted, given their order. The pair is (lower_address_word, higher_address_word).

    int32_parts

    fn int32_parts(v : Int) -> (Int, Int)

    Split a 32-bit int into its high and low 16-bit words (unsigned).

    int32_words

    fn int32_words(v : Int, order : WordOrder) -> (Int, Int)

    The two words of a 32-bit int in the register order they occupy on the wire: the first element goes to the lower register address.

    is_jsonb_prefixed

    fn is_jsonb_prefixed(s : String) -> Bool

    True when the raw field uses the JsonB prefix jsonb->.

    json_roundtrip

    fn json_roundtrip(dev : Device) -> Result[Device, String]

    Round-trip a Device through JSON.

    jsonb_depth

    fn jsonb_depth(s : String) -> Int

    The number of segments in a parseable jsonb path (or 0 when unparseable).

    jsonb_is_unset

    fn jsonb_is_unset(s : String) -> Bool

    True when the raw jsonb string is effectively empty / unused.

    jsonb_leaf

    fn jsonb_leaf(s : String) -> String

    The final (leaf) segment of a jsonb path, used for naming hints.

    lint

    fn lint(dev : Device) -> Array[Issue]

    Run every rule and return all findings (with the default gap threshold).

    lint_with_gap

    fn lint_with_gap(dev : Device, threshold : Int) -> Array[Issue]

    Run every rule and return all findings, using threshold as the max allowed address gap (in words) for Rule 6. lint(dev) is lint_with_gap(dev, 16).

    lookup

    fn lookup(dev : Device, name : String) -> Register?

    Find a register by exact name. Returns None when it is absent.

    pad

    fn pad(s : String) -> String

    Right-pad a string to at least 14 columns so columns line up when the CLI prints a sorted point table.

    pad_num

    fn pad_num(n : Int) -> String

    Left-pad an integer to at least 7 columns (right-aligned) for the CLI point-table view.

    parse_device

    fn parse_device(text : String) -> Result[Device, String]

    Parse a whole point-table document.

    parse_device_json

    fn parse_device_json(text : String) -> Result[Device, String]

    Parse a JSON string back into a Device.

    parse_jsonb_path

    fn parse_jsonb_path(s : String) -> Result[Array[String], String]

    Parse a jsonb field into its dotted segments that follow the jsonb-> prefix. Returns Err with a human message when the value is empty, lacks the prefix, an empty segment appears, or a segment contains an invalid character.

    per_area_words

    fn per_area_words(dev : Device) -> Array[(Int, Int)]

    How many 16-bit words each area holds in total (occupied allocation).

    render

    fn render(issues : Array[Issue]) -> String

    Render all findings. Returns a short "OK" message when there are none.

    render_by_area

    fn render_by_area(dev : Device) -> String

    Renders the point table grouped by Modbus area, with the registers sorted by address within each area. Useful for eyeballing allocation before linting.

    render_csv

    fn render_csv(issues : Array[Issue]) -> String

    以 CSV 形式输出(含表头),适合导入 Excel / 电子表格继续分析。

    render_html

    fn render_html(issues : Array[Issue]) -> String

    以 HTML 表格形式输出,便于在浏览器中直接查看或嵌入到报告页。

    render_issues_json

    fn render_issues_json(issues : Array[Issue]) -> String

    Render lint findings as a JSON array string.

    render_json

    fn render_json(issues : Array[Issue]) -> String

    以 JSON 数组形式输出,便于 CI 或其他工具消费。

    render_markdown

    fn render_markdown(issues : Array[Issue]) -> String

    Renders the findings as a Markdown table (one row per finding), which is convenient to paste into issue trackers or MR descriptions.

    render_summary

    fn render_summary(dev : Device, issues : Array[Issue]) -> String

    A compact summary: total registers, how many errors/warnings, the areas touched, and the number of registers with a jsonb mapping.

    severity_name

    fn severity_name(sev : Severity) -> String

    Lower-case name of a severity, used in JSON reports.

    sort_by_address

    fn sort_by_address(dev : Device) -> Device

    Returns a new Device whose registers are sorted by address (stable insertion sort, so equal addresses keep their original relative order).

    stats_summary

    fn stats_summary(dev : Device) -> String

    A one-shot human summary of the point-table shape (counts per area/type/access).

    stats_text

    fn stats_text(dev : Device) -> String

    Render the stats snapshot as a compact text block (used by the CLI summary).

    total_free_words

    fn total_free_words(stats : DeviceStats) -> Int

    Total free words across every real area.

    type_name

    fn type_name(rtype : RegisterType) -> String

    Human-readable keyword for a register type, symmetric with parse_type.

    uint16_to_bytes

    fn uint16_to_bytes(w : Int, order : ByteOrder) -> (Int, Int)

    The two bytes (ints in 0..=255) of a 16-bit word under order.

    word_count

    fn word_count(rtype : RegisterType) -> Int

    Number of 16-bit words a register type occupies.

    word_order_name

    fn word_order_name(order : WordOrder) -> String

    Human keyword for a word order, for reports and (de)serialization.

    word_order_of

    fn word_order_of(name : String) -> WordOrder

    Resolve a keyword ("hi-lo"/"lo-hi", case-insensitive) into a WordOrder, defaulting to native order (high word at the lower address).