B&R

Save & analyze watch window snapshots (.pvm)

A watch window (variable monitor) shows live process-variable values while you’re connected to a PLC. Less obviously, saving a watch configuration also snapshots the current values — so a single .pvm file is both a reusable variable list and a machine-state capture you can archive, hand off, or diff offline. This guide covers saving a snapshot and analyzing it offline with a few small Python scripts.

Objective

Save a watch window snapshot that captures live variable values, then search or diff those snapshots offline as JSON — with no live connection to the machine.

Prerequisites

  • Automation Studio with an online connection to the target (a real PLC or ARsim) — values are only captured while connected. The watch window (variable monitor) is a standard diagnostics tool in AS 4.x and 6.x.
  • For offline analysis: Python 3.8+ (standard library only — no third-party packages). The scripts you need are included below, so there’s nothing to install or clone.

Save a watch window snapshot

1. Open a watch window

Open the Logical View, right-click the task you want to inspect, and select Open / Watch (or press Ctrl + W).

Watch window

2. Add the variables you need

Click the Insert variable toolbar icon and pick variables from the dialog, or drag them in from the programming editor.

Insert variable toolbar button

Insert variable dialog

3. Expand and scroll the values into view

Go online so live values appear in the Value column, then expand every structure and array whose values you need and make sure each row is scrolled through the visible area at least once. Reading a row is what refreshes its value — per B&R’s documentation, “the variable values of the configuration are not completely updated before saving … no or outdated values are saved for variables that are outside the visible area of the watch window when saving.”

The quickest way to do this is the Expand All button on the watch window toolbar (or Shift + Num +), which opens every structure and array at once. Click it, then scroll from top to bottom so every row passes through view before you save.

Expand All button

4. Save the configuration

Click Saving the Watch configuration (disk icon) in the toolbar, or use Watch / Save Watch configuration. Enter a name for the snapshot (or select an existing one to overwrite).

The file is written to the project’s Diagnosis folder:

<ProjectPath>\Diagnosis\<Configuration>\<PCCName>\<Name>.pvm

It is a plain-text table of each variable’s name, type, and value at the moment of saving.

A * in the value column is normal for a structure or array — a non-primitive node has no scalar value of its own, so it shows * whether or not you expanded it. A * only signals a missing value when it lands on a primitive (a leaf such as INT, BOOL, or REAL): that means the value wasn’t read before saving, because the variable was collapsed inside a parent or scrolled out of view. Those are the values you’ll be missing offline.

name                       type                 value
axisPar                    MpAxisBasicParType   "   *"       <- structure: no scalar value of its own (normal)
  Position                 LREAL                "123.456"    <- primitive, expanded & visible: value captured
  Velocity                 REAL                 "500.0"      <- primitive, expanded & visible: value captured
Jog                        MpAxisJogType        "   *"       <- structure: normal
  Velocity                 REAL                 "   *"       <- primitive, not scrolled into view: value NOT captured

Saved .pvm open in a text editor

Capturing multiple snapshots. Because the save records a point in time, before/after debugging is just a matter of re-saving after each event with a distinct name — same watch layout, multiple files to diff later:

AxisTest before restore.pvm
AxisTest after restore.pvm

Analyze the snapshot offline

You don’t need a live connection — or the original project — to read a .pvm. The Python scripts below (standard library only, Python 3.8+) turn one into searchable JSON. Save them anywhere and run them from the folder that holds your .pvm files.

1. Convert the .pvm to JSON

python pvm_to_json.py AxisTest.PVM

Writes AxisTest.json next to the input. Run it with no arguments to convert every *.pvm in the current folder; add --full to include each variable’s IEC type. A * on a leaf becomes null in the JSON.

AxisTest.json — the nested JSON output

pvm_to_json.py
#!/usr/bin/env python3
"""Convert B&R Automation Studio watch-window PVM files to JSON.

A PVM file is a whitespace-separated dump of a watch window:
  - Line 1: WATCH header (Ver, PLCName, CPUName, TaskName, ...)
  - Line 2: window Position/layout info (ignored)
  - Line 3: column header (name, type, force, value, level, ...)
  - Remaining lines: one variable per line. A value of "*" on a row whose
    `level` is followed by deeper rows means the row is a structure (or
    array) and its members follow. A "*" on a leaf means the value was not
    captured/expanded in the watch window -> emitted as null.

Array members appear as `name[0]`, `name[1]`, ... nested under a row whose
type is e.g. `DINT[0..99]`; these are collapsed into JSON arrays.

Usage:
    python pvm_to_json.py                  # convert every *.pvm in cwd
    python pvm_to_json.py file1.pvm ...    # convert specific files
    python pvm_to_json.py --full ...       # include type/force metadata per node
    python pvm_to_json.py -o out.json f.pvm  # explicit output (single input only)
"""

import argparse
import json
import re
import sys
from pathlib import Path

# name  type  force  "value"  level typecode len format place expand [specs]
ROW_RE = re.compile(
    r'^\s*(?P<name>\S+)'
    r'\s+(?P<type>\S+)'
    r'\s+(?P<force>\d+)'
    r'\s+"(?P<value>.*)"'
    r'\s+(?P<level>\d+)'
    r'\s+(?P<typecode>\d+)'
    r'\s+(?P<len>\d+)'
    r'\s+(?P<format>\d+)'
    r'\s+(?P<place>\d+)'
    r'\s+(?P<expand>\d+)'
    r'\s*(?P<specs>\S*)\s*$'
)

ARRAY_ELEM_RE = re.compile(r'^(?P<base>.+)\[(?P<index>\d+)\]$')

NUM_RE = re.compile(r'^-?\d+$')
FLOAT_RE = re.compile(r'^-?\d*\.\d+([eE][+-]?\d+)?$|^-?\d+[eE][+-]?\d+$')


def convert_value(raw):
    """Convert the quoted value field to a native JSON value."""
    v = raw.strip()
    if v == '*':
        return None  # not captured / structure placeholder
    if v.startswith("'") and v.endswith("'"):
        return v[1:-1]
    if v == 'TRUE':
        return True
    if v == 'FALSE':
        return False
    if NUM_RE.match(v):
        return int(v)
    if FLOAT_RE.match(v):
        return float(v)
    return v


def parse_watch_header(line):
    """Parse `WATCH Ver=2.00 PLCName=PLC1 ...` into a dict."""
    meta = {}
    for key, val in re.findall(r'(\w+)=(\S+)', line):
        meta[key] = val
    return meta


def parse_rows(lines):
    """Yield parsed row dicts, skipping headers and blank wrap lines."""
    for lineno, line in enumerate(lines, 1):
        if lineno <= 3 or not line.strip():
            continue
        m = ROW_RE.match(line)
        if not m:
            raise ValueError(f'line {lineno}: unrecognized row: {line!r}')
        yield {
            'name': m['name'],
            'type': m['type'],
            'force': int(m['force']),
            'value': convert_value(m['value']),
            'level': int(m['level']),
        }


def build_tree(rows, full=False):
    """Build a nested dict from the flat row list using the level column.

    Each node starts as a dict with bookkeeping keys; finalize() collapses
    leaves to plain values and groups `name[i]` children into arrays.
    """
    root = {'children': [], 'name': '<root>'}
    stack = [root]  # stack[i] is the open node at level i-1

    for row in rows:
        level = row['level']
        # pop back to the parent of this level
        if level + 1 > len(stack):
            raise ValueError(
                f"row {row['name']!r}: level {level} jumps past current depth "
                f'{len(stack) - 1}'
            )
        del stack[level + 1:]
        node = {**row, 'children': []}
        stack[level]['children'].append(node)
        stack.append(node)

    return finalize(root, full)['value'] if root['children'] else {}


def finalize(node, full):
    """Recursively collapse a node to {'name': ..., 'value': json-ready}."""
    children = node.get('children', [])
    if not children:
        value = node['value']
        if full:
            value = {'type': node['type'], 'value': node['value']}
            if node['force']:
                value['force'] = node['force']
        return {'name': node['name'], 'value': value}

    done = [finalize(c, full) for c in children]

    # array? all children named like parent[index]
    base = node.get('name')
    indices = []
    for c, raw in zip(done, children):
        m = ARRAY_ELEM_RE.match(raw['name'])
        if m and (base in ('<root>',) or m['base'] == base):
            indices.append(int(m['index']))
        else:
            indices = None
            break
    if indices is not None and base != '<root>':
        arr = [None] * (max(indices) + 1)
        for idx, c in zip(indices, done):
            arr[idx] = c['value']
        value = arr
    else:
        value = {}
        for c in done:
            name = c['name']
            if name in value:
                # duplicate sibling name: keep both by suffixing
                n = 2
                while f'{name}~{n}' in value:
                    n += 1
                print(f'warning: duplicate member {name!r}, stored as '
                      f'{name}~{n}', file=sys.stderr)
                name = f'{name}~{n}'
            value[name] = c['value']

    if full and node.get('type'):
        value = {'type': node['type'], 'value': value}
    return {'name': node.get('name', '<root>'), 'value': value}


def convert_file(path, full=False):
    lines = Path(path).read_text(encoding='utf-8', errors='replace').splitlines()
    if not lines or not lines[0].startswith('WATCH'):
        raise ValueError(f'{path}: does not look like a PVM file '
                         f'(missing WATCH header)')
    meta = parse_watch_header(lines[0])
    rows = list(parse_rows(lines))
    return {
        'meta': meta,
        'variables': build_tree(rows, full=full),
    }


def main():
    ap = argparse.ArgumentParser(
        description='Convert B&R watch-window PVM files to JSON.')
    ap.add_argument('files', nargs='*',
                    help='PVM files to convert (default: all *.pvm in cwd)')
    ap.add_argument('--full', action='store_true',
                    help='include type (and force, when set) metadata per variable')
    ap.add_argument('-o', '--output',
                    help='output file (only valid with a single input; '
                         'default: <input>.json next to each input)')
    args = ap.parse_args()

    files = [Path(f) for f in args.files]
    if not files:
        files = sorted(Path('.').glob('*.[pP][vV][mM]'))
        if not files:
            ap.error('no PVM files given and none found in current directory')
    if args.output and len(files) > 1:
        ap.error('-o/--output requires exactly one input file')

    for path in files:
        result = convert_file(path, full=args.full)
        out = Path(args.output) if args.output else path.with_suffix('.json')
        out.write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8')
        print(f'{path} -> {out}')


if __name__ == '__main__':
    main()

2. Flatten for quick searching

python flatten_json.py AxisTest.json

Produces a *.flat.json with one variable per line, keyed by full dotted path — open it in VS Code and Ctrl + F for any variable:

{
"axisPar.Velocity": 500.0,
"gAxis.sts.homed": true,
"gAxis.sts.actPosition": 123.456,
"gMainAxis.controlif": null
}

AxisTest.flat.json — one variable per dotted path, ready to search with Ctrl + F

flatten_json.py
#!/usr/bin/env python3
"""Flatten PVM-derived JSON files for easy searching in an editor.

Turns the nested `variables` tree into a flat object keyed by dotted path,
one variable per line:

    {
      "axisPar.Velocity": 500.0,
      "gAxis.sts.homed": true,
      "gAxis.sts.actPosition": 123.456,
      "gMainAxis.controlif": null,
      ...
    }

Usage:
    python flatten_json.py                 # flatten every *.json in cwd
    python flatten_json.py file.json ...   # flatten specific files

Output is written next to each input as <name>.flat.json. Already-flattened
files (*.flat.json) are skipped.
"""

import argparse
import json
from pathlib import Path


def flatten(node, prefix=""):
    if isinstance(node, dict):
        for k, v in node.items():
            yield from flatten(v, f"{prefix}.{k}" if prefix else k)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            yield from flatten(v, f"{prefix}[{i}]")
    else:
        yield prefix, node


def main():
    ap = argparse.ArgumentParser(
        description="Flatten PVM-derived JSON to one dotted path per line.")
    ap.add_argument("files", nargs="*",
                    help="JSON files to flatten (default: all *.json in cwd)")
    args = ap.parse_args()

    files = [Path(f) for f in args.files]
    if not files:
        files = [f for f in sorted(Path(".").glob("*.json"))
                 if not f.name.endswith(".flat.json")]
        if not files:
            ap.error("no JSON files given and none found in current directory")

    for f in files:
        if f.name.endswith(".flat.json"):
            print(f"{f.name}: already flat, skipping")
            continue
        data = json.loads(f.read_text(encoding="utf-8"))
        tree = data.get("variables", data)  # accept raw or {meta, variables}
        flat = dict(flatten(tree))
        out = f.with_name(f.stem + ".flat.json")
        body = json.dumps(flat, indent=0)  # one "path": value per line
        out.write_text(body + "\n", encoding="utf-8")
        print(f"{f.name} -> {out.name} ({len(flat)} variables)")


if __name__ == "__main__":
    main()

3. (Optional) Query from the command line

Prefer the terminal? find_var.py searches a snapshot by exact path, substring, or wildcard, and can diff a variable across every snapshot in the folder:

python find_var.py gAxis.sts.homed AxisTest.json      # exact path
python find_var.py actPosition AxisTest.json          # substring, case-insensitive
python find_var.py "gAxis.sts.stPos*" AxisTest.json   # wildcard
python find_var.py gAxis.sts.homed --all              # diff across every *.json
find_var.py
#!/usr/bin/env python3
"""Look up variables in PVM-derived JSON files by (partial) dotted path.

Flattens each JSON file to dotted paths (e.g. gAxis.sts.homed) and
prints every path that matches the query. Matching is case-insensitive:
  - exact path        gAxis.sts.homed
  - substring         actPosition, position
  - wildcards         gAxis.sts.stPos*, axisPar.*

Usage:
    python find_var.py gAxis.sts.homed AxisTest.json
    python find_var.py "gAxis.sts.stPos*" AxisTest.json
    python find_var.py actPosition --all               # search every *.json in cwd
"""

import argparse
import fnmatch
import json
from pathlib import Path


def flatten(node, prefix=""):
    if isinstance(node, dict):
        for k, v in node.items():
            yield from flatten(v, f"{prefix}.{k}" if prefix else k)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            yield from flatten(v, f"{prefix}[{i}]")
    else:
        yield prefix, node


def find_hits(pairs, query):
    """Return matching (path, value) pairs. Exact full-path matches win;
    otherwise fall back to wildcard/substring matching."""
    q = query.lower()
    exact = [(p, v) for p, v in pairs if p.lower() == q]
    if exact:
        return exact
    if "*" in q or "?" in q:
        return [(p, v) for p, v in pairs
                if fnmatch.fnmatchcase(p.lower(), q)
                or fnmatch.fnmatchcase(p.lower(), f"*{q}*")]
    return [(p, v) for p, v in pairs if q in p.lower()]


def main():
    ap = argparse.ArgumentParser(
        description="Find variables in PVM-derived JSON files by dotted path.")
    ap.add_argument("query", help="full or partial dotted path; * and ? wildcards allowed")
    ap.add_argument("file", nargs="?", help="JSON file to search")
    ap.add_argument("--all", action="store_true",
                    help="search every *.json in the current directory")
    args = ap.parse_args()

    if args.all:
        files = [f for f in sorted(Path(".").glob("*.json"))
                 if not f.name.endswith(".flat.json")]
        if not files:
            ap.error("no *.json files found in current directory")
    elif args.file:
        files = [Path(args.file)]
    else:
        candidates = [f for f in sorted(Path(".").glob("*.json"))
                      if not f.name.endswith(".flat.json")]
        if len(candidates) == 1:
            files = candidates
        else:
            ap.error("specify a JSON file (or use --all to search every file):\n  "
                     + "\n  ".join(f.name for f in candidates))

    total = 0
    for f in files:
        data = json.loads(f.read_text(encoding="utf-8"))
        tree = data.get("variables", data)  # accept raw or {meta, variables}
        hits = find_hits(list(flatten(tree)), args.query)
        if hits:
            print(f"\n{f.name}:")
            for path, value in hits:
                print(f"  {path} = {json.dumps(value)}")
            total += len(hits)
    if total == 0:
        print(f"no match for {args.query!r}")


if __name__ == "__main__":
    main()

Verification

  • The .pvm exists in the Diagnosis folder and, opened in a text editor, shows real values (not *) for the primitive variables you expanded.
  • After conversion, the variable you care about has a real value in the JSON. A null on a leaf means the node was not expanded or visible when the snapshot was saved — the value is not in the file at all.

Troubleshooting

  • A primitive shows * (in the .pvm) or null (in the JSON). It was collapsed or scrolled out of the visible area when the snapshot was saved. Expand it, scroll it into view, and re-save — the value can’t be recovered from the existing file. (A * on a structure or array is normal and needs no action.)
  • Keep captured data out of source control. Add *.pvm and *.json to your .gitignore so snapshots dropped into a working copy aren’t committed by accident; treat machine captures as confidential regardless.
  • B&R Help: Saving a configuration (GUID: df1b9fad-b6b7-408f-8015-837763828c01) and Watch window – General information (GUID: 0c01db60-35e6-49b8-a0a3-f517e64c2bcc).
  • The scripts above are maintained in loupeteam/pvm-tools (Loupe internal), which also includes a verify_nulls.py sanity-checker and example files.