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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.py text eol=lf
87 changes: 45 additions & 42 deletions .github/workflows/build_plugin.yml
Original file line number Diff line number Diff line change
@@ -1,54 +1,57 @@
name: Plugin build

on:
workflow_dispatch: # Manual trigger from GitHub UI
pull_request:
workflow_dispatch:
inputs:
duetwebcontrol_ref:
description: 'Enter the tag, branch or SHA of the Duet3D/DuetWebControl repository to use.'
description: DuetWebControl tag, branch or SHA (3.7 or newer)
required: true
default: v3.7.0

permissions:
contents: read

jobs:
build:
backend-test:
runs-on: ubuntu-latest
strategy:
matrix:
dsf_python_ref:
- 23308a2bdb90d34f6904541f42b008755bbae072 # 3.6
- 9fbeb14607a0ef82bfc1180239858fa3b08c03ce # 3.7
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: python -m pip install "git+https://github.com/Duet3D/dsf-python.git@${{ matrix.dsf_python_ref }}"
- run: python -m unittest discover -s tests -v

build:
runs-on: ubuntu-latest
steps:
- name: Checkout current repository
uses: actions/checkout@v4
with:
- uses: actions/checkout@v4
with:
path: plugin

- name: Checkout DuetWebControl repository
uses: actions/checkout@v4
with:
repository: Duet3D/DuetWebControl
ref: ${{ github.event.inputs.duetwebcontrol_ref }}
path: DuetWebControl

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: latest

- name: Install dependencies in DuetWebControl
working-directory: DuetWebControl
run: npm install

- name: Build plugin
working-directory: DuetWebControl
run: npm run build-plugin ../plugin/src

- name: Rename plugin zip file
id: rename-zip
working-directory: DuetWebControl
run: |
zipfile=$(ls dist/ExecOnMcode-*.zip)
base=$(basename "$zipfile" .zip)
newname="${base}-${{ github.event.inputs.duetwebcontrol_ref }}.zip"
mv "$zipfile" "$newname"
echo "zipname=$(basename $newname .zip)" >> "$GITHUB_OUTPUT"

- name: Upload plugin archive
uses: actions/upload-artifact@v4
with:
name: ExecOnMcode_toUnzip
path: DuetWebControl/${{ steps.rename-zip.outputs.zipname }}.zip
- uses: actions/checkout@v4
with:
repository: Duet3D/DuetWebControl
ref: ${{ inputs.duetwebcontrol_ref || 'v3.7.0' }}
path: DuetWebControl
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Test frontend command handling
working-directory: plugin
run: npm test
- name: Install DWC build dependencies
working-directory: DuetWebControl
run: npm ci --no-audit --no-fund
- name: Build and package plugin
working-directory: DuetWebControl
run: node scripts/build-plugin-pkg.js ../plugin/src
- uses: actions/upload-artifact@v4
with:
name: ExecOnMcode-3.7
path: plugin/src/ExecOnMcode-*.zip
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
src/dist/
src/pkg/
src/*.zip
60 changes: 60 additions & 0 deletions MIGRATION-3.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# DWC / DSF 3.7 migration

This branch targets DuetWebControl 3.7.0 and DuetSoftwareFramework 3.7.0.
The DWC frontend requires Vue 3, Vuetify 4, Pinia and the Vite plugin loader.
The backend supports both dsf-python 3.6 (IPC 12) and 3.7 (IPC 13), because a
DSF 3.7 installation can still carry the older Python package.

## Changes

- Port route registration and the settings UI to the DWC 3.7 plugin API.
- Read/write configuration through standard DWC download/upload actions,
replacing the custom HTTP listener. Opening the panel only reads the file.
- After a successful save, send the existing `M1200` filter-reload code.
Configured commands are not executed by the editor.
- Handle both `resolve_path()` return forms, enum naming conventions and
`majorNumber` / `major_number` without changing the installed Python package.
- Preserve the configuration path and custom fields. Editing uses a copy so
cancelling an edit cannot mutate the live table.
- Validate duplicate/reserved M-codes and allocate free codes from unsorted lists.
- Enforce LF line endings for executable Python scripts on Windows checkouts.
- Update the packaging workflow to the DWC 3.7 build script.

The custom HTTP endpoint implementation is removed; configuration clients should
use the standard DSF/DWC file APIs for `0:/sys/ExecOnMcode/ExecOnMcode.json` and
reload filters with `M1200` after saving. The configuration file is not part of
the installation payload, so upgrades preserve existing commands.

## Build and test

With DWC 3.7.0 checked out at `/path/to/DuetWebControl` and its npm dependencies
installed, run from this repository:

```sh
npm test
python -m pip install -r requirements-test.txt
python -m unittest discover -s tests -v
node /path/to/DuetWebControl/scripts/build-plugin-pkg.js src
```

Node.js 22.18+ or 24 LTS is recommended; the Python tests support Python 3.11+.
The ZIP is written to `src/ExecOnMcode-<version>.zip`. Both DWC source type checking
and Vite bundling are performed by the build script. CI runs the backend tests
against pinned revisions of the official dsf-python 3.6 and 3.7 clients.

Tests cover configuration loading/defaults, preservation of extra fields,
validation, save/reload ordering, upload/reload failures, compatibility adapters,
and interception with subprocess execution mocked out.

## Hardware validation and limits

The rc.5 migration was installed on a DSF/DWC 3.7.0 system with dsf-python 3.6.0.
This rc.6 proposal additionally extracts the configuration transport for isolated
testing and removes the unused custom HTTP implementation; those final changes
were checked locally and have not been reinstalled on the live system.
The backend remained running, the frontend displayed the existing configuration,
and the configuration bytes were unchanged after installation. No configured
command or G-code was executed, and no save/delete operation was performed on the
live configuration. End-to-end live save/reload and actual command execution
therefore remain for maintainer validation. No firmware or system packages were
updated during this validation.
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# DSF_ExecOnMcode_Plugin

This branch supports **DuetWebControl / DuetSoftwareFramework 3.7**.
See [MIGRATION-3.7.md](MIGRATION-3.7.md) for API changes, build/test instructions
and the scope of hardware validation.

![ExecOnMcode](https://user-images.githubusercontent.com/974748/212554271-af1bf32e-a8fa-4a3b-988b-4c76a5a31052.png)

## Description
Expand All @@ -15,12 +19,12 @@ Command arguments can be passed from G-Code parameters, e.g: when issuing `Mxxx

## Installation

1. Go on the `Plugins` Tab of Duet Web Control.
2. Select `External plugins`.
3. Click on *Upload System Files* and select the plugin zip archive.
4. Follow the on-screen instructions to install the plugin.
5. Click on the `Start` button to start the plugin.
6. *ExecOnMcode* entry should appears under the *Settings* tab of DWC.
1. Open the **Plugins** page in Duet Web Control 3.7.
2. Select **Install plugin**, choose the plugin ZIP and follow the installation steps.
3. Start the plugin. Open **ExecOnMcode** under **Preferences** to edit commands.

Configuration is stored at `0:/sys/ExecOnMcode/ExecOnMcode.json`. Opening the
panel only reads it; saving uploads the file and sends `M1200` to reload filters.

## Limitations

Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "exec-on-mcode-migration",
"private": true,
"type": "module",
"scripts": {
"test": "node --experimental-strip-types --test tests/*.test.mjs"
}
}
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dsf-python @ git+https://github.com/Duet3D/dsf-python.git@9fbeb14607a0ef82bfc1180239858fa3b08c03ce
29 changes: 29 additions & 0 deletions src/dsf/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""The DSF 3.7 server is also deployed with older dsf-python package revisions."""
from dsf.commands.code import CodeType
from dsf.object_model.messages import MessageType
from dsf.object_model.state.log_level import LogLevel


def enum_value(enum, current, legacy):
# Do not evaluate the fallback eagerly: some releases only provide one name.
if hasattr(enum, current):
return getattr(enum, current)
return getattr(enum, legacy)


SUCCESS = enum_value(MessageType, "SUCCESS", "Success")
ERROR = enum_value(MessageType, "ERROR", "Error")
INFO = enum_value(LogLevel, "INFO", "Info")
WARN = enum_value(LogLevel, "WARN", "Warn")
MCODE = enum_value(CodeType, "MCODE", "MCode")


def resolved_path(result):
"""New clients return a string; earlier clients wrap it in a response."""
return result if isinstance(result, str) else result.result


def major_number(code):
if hasattr(code, "major_number"):
return code.major_number
return code.majorNumber
33 changes: 14 additions & 19 deletions src/dsf/execOnMcode.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
#!/usr/bin/python3

import json
import dsf
import subprocess
import traceback
from pathlib import Path

from dsf.commands.code import CodeType
from dsf.connections import CommandConnection, InterceptConnection, InterceptionMode
from dsf.object_model.messages import MessageType
from dsf.object_model.state.log_level import LogLevel

from http_endpoints import custom_http_endpoints
from MCodeAction import MCodeAction
from compat import SUCCESS, ERROR, INFO, WARN, MCODE, resolved_path, major_number


DEFAULT_FILTERS = ["M1200"]
Expand All @@ -34,7 +32,7 @@ def __update_interception_filters(intercept_connection):
actions = get_actions_from_config()
filters = __get_filters_from_actions(actions)
msg = f"{PLUGIN_NAME}: Interception filters successfully updated"
intercept_connection.resolve_code(MessageType.Success, msg)
intercept_connection.resolve_code(SUCCESS, msg)
intercept_connection.close()
intercept_connection = InterceptConnection(InterceptionMode.PRE, filters=filters)
intercept_connection.connect()
Expand Down Expand Up @@ -68,11 +66,11 @@ def __do_action_for_code(intercept_connection, actions, code):

# Resolve the received code and return result
if error_msg:
intercept_connection.resolve_code(MessageType.Error, error_msg)
intercept_connection.resolve_code(ERROR, error_msg)
else:
# Display the command output if not explicitly requested to be hidden
msg = f"[{PLUGIN_NAME}]: {out.stdout}" if out and out.stdout and not action.cmd_capture_output else None
intercept_connection.resolve_code(MessageType.Success, msg)
intercept_connection.resolve_code(SUCCESS, msg)


def intercept_mcodes(actions):
Expand All @@ -86,11 +84,11 @@ def intercept_mcodes(actions):
code = intercept_connection.receive_code()

# Check for the type of the code
if code.type != CodeType.MCode:
if code.type != MCODE:
intercept_connection.ignore_code()
continue

if code.majorNumber == 1200: # Update interception filters
if major_number(code) == 1200: # Update interception filters
intercept_connection, filters, actions = __update_interception_filters(intercept_connection)
elif code.short_str() in filters: # Do action from JSON file
__do_action_for_code(intercept_connection, actions, code)
Expand All @@ -103,15 +101,15 @@ def intercept_mcodes(actions):
intercept_connection.close()


def write_message(msg, msgType=MessageType.Success, loglvl=LogLevel.Info):
def write_message(msg, msgType=SUCCESS, loglvl=INFO):
cmd_conn.write_message(msgType, f"{PLUGIN_NAME}: {msg}", True, loglvl)


def get_actions_from_config():
actions = []
# Use DSF API to get the physical path to the configuration file
res = cmd_conn.resolve_path(f"0:/sys/{PLUGIN_NAME}/{PLUGIN_NAME}.json")
filter_filepath = Path(res.result)
filter_filepath = Path(resolved_path(res))
if not filter_filepath.is_file():
# Create missing parent directories
filter_filepath.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -142,29 +140,26 @@ def get_actions_from_config():
json_filter = json.load(fp)
except json.decoder.JSONDecodeError as e:
json_filter = []
write_message(e, MessageType.Error, LogLevel.Warn)
write_message(e, ERROR, WARN)
for action in json_filter:
if action['cmd_code'] in DEFAULT_FILTERS:
write_message(
f"{action['cmd_code']} is a reserved filter and thus it can't be used.",
MessageType.Error,
LogLevel.Warn)
ERROR,
WARN)
continue
try:
actions.append(MCodeAction(action))
except KeyError as e:
write_message(e, MessageType.Error, LogLevel.Warn)
write_message(e, ERROR, WARN)
return actions


if __name__ == "__main__":
endpoints = []
cmd_conn = CommandConnection()
try:
cmd_conn.connect()
endpoints = custom_http_endpoints(cmd_conn)
print(f"ExecOnMcode: dsf-python {getattr(dsf, '__version__', 'unknown')}, protocol {dsf.PROTOCOL_VERSION}", flush=True)
intercept_mcodes(get_actions_from_config())
finally:
for endpoint in endpoints:
endpoint.close()
cmd_conn.close()
Loading
Loading