mapcan 0.23.0__tar.gz → 0.23.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. {mapcan-0.23.0 → mapcan-0.23.2}/PKG-INFO +1 -1
  2. {mapcan-0.23.0 → mapcan-0.23.2}/docs/format.md +19 -3
  3. {mapcan-0.23.0 → mapcan-0.23.2}/docs/web.md +9 -5
  4. {mapcan-0.23.0 → mapcan-0.23.2}/examples/minimal.yaml +1 -2
  5. {mapcan-0.23.0 → mapcan-0.23.2}/pyproject.toml +1 -1
  6. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/__init__.py +1 -1
  7. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/cli.py +6 -1
  8. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fmt.py +28 -0
  9. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/launcher.py +1 -1
  10. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/locale/ru.py +25 -1
  11. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/lsp/analysis.py +50 -15
  12. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/lsp/edits.py +65 -14
  13. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/lsp/server.py +43 -1
  14. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/validate.py +63 -14
  15. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/app.py +26 -6
  16. mapcan-0.23.2/src/mapcan/web/devicefile.py +113 -0
  17. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/session.py +154 -43
  18. mapcan-0.23.2/src/mapcan/web/static/assets/index-B4ld-hyP.js +35 -0
  19. mapcan-0.23.2/src/mapcan/web/static/assets/index-qvZIhvUc.css +1 -0
  20. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/static/index.html +2 -2
  21. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/yamlops.py +120 -11
  22. mapcan-0.23.2/tests/test_device_file.py +258 -0
  23. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_eds.py +1 -1
  24. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_fmt.py +9 -0
  25. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_include.py +28 -2
  26. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_lsp.py +17 -1
  27. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_lsp_edits.py +35 -4
  28. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_validate.py +52 -1
  29. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_web.py +12 -2
  30. mapcan-0.23.2/tests/test_yamlops.py +128 -0
  31. mapcan-0.23.0/src/mapcan/web/static/assets/index-BTf8uc75.js +0 -31
  32. mapcan-0.23.0/src/mapcan/web/static/assets/index-S8VlG2gr.css +0 -1
  33. mapcan-0.23.0/tests/test_yamlops.py +0 -71
  34. {mapcan-0.23.0 → mapcan-0.23.2}/.gitignore +0 -0
  35. {mapcan-0.23.0 → mapcan-0.23.2}/LICENSE +0 -0
  36. {mapcan-0.23.0 → mapcan-0.23.2}/NOTICE +0 -0
  37. {mapcan-0.23.0 → mapcan-0.23.2}/README.md +0 -0
  38. {mapcan-0.23.0 → mapcan-0.23.2}/THIRD_PARTY_NOTICES.md +0 -0
  39. {mapcan-0.23.0 → mapcan-0.23.2}/docs/ci-example.md +0 -0
  40. {mapcan-0.23.0 → mapcan-0.23.2}/docs/cli.md +0 -0
  41. {mapcan-0.23.0 → mapcan-0.23.2}/docs/format-changelog.md +0 -0
  42. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/demo-dark.gif +0 -0
  43. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/demo-light.gif +0 -0
  44. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/document-pages-dark.png +0 -0
  45. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/document-pages-light.png +0 -0
  46. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-demo-dark.gif +0 -0
  47. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-demo-light.gif +0 -0
  48. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-hover-dark.png +0 -0
  49. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-hover-light.png +0 -0
  50. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-map-dark.png +0 -0
  51. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-map-light.png +0 -0
  52. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-problems-dark.png +0 -0
  53. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-problems-light.png +0 -0
  54. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-quickfix-dark.gif +0 -0
  55. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/vscode-quickfix-light.gif +0 -0
  56. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/web-frame-dark.png +0 -0
  57. {mapcan-0.23.0 → mapcan-0.23.2}/docs/images/web-frame-light.png +0 -0
  58. {mapcan-0.23.0 → mapcan-0.23.2}/docs/logo-dark.svg +0 -0
  59. {mapcan-0.23.0 → mapcan-0.23.2}/docs/logo-light.svg +0 -0
  60. {mapcan-0.23.0 → mapcan-0.23.2}/docs/templates.md +0 -0
  61. {mapcan-0.23.0 → mapcan-0.23.2}/docs/zephyr.md +0 -0
  62. {mapcan-0.23.0 → mapcan-0.23.2}/examples/canopen-node.yaml +0 -0
  63. {mapcan-0.23.0 → mapcan-0.23.2}/examples/j1939.yaml +0 -0
  64. {mapcan-0.23.0 → mapcan-0.23.2}/examples/tractor/powertrain.yaml +0 -0
  65. {mapcan-0.23.0 → mapcan-0.23.2}/examples/tractor/tractor.yaml +0 -0
  66. {mapcan-0.23.0 → mapcan-0.23.2}/examples/vehicle-can.yaml +0 -0
  67. {mapcan-0.23.0 → mapcan-0.23.2}/examples/zephyr/CMakeLists.txt +0 -0
  68. {mapcan-0.23.0 → mapcan-0.23.2}/examples/zephyr/prj.conf +0 -0
  69. {mapcan-0.23.0 → mapcan-0.23.2}/examples/zephyr/src/main.c +0 -0
  70. {mapcan-0.23.0 → mapcan-0.23.2}/schema/mapcan-part.schema.json +0 -0
  71. {mapcan-0.23.0 → mapcan-0.23.2}/schema/mapcan.schema.json +0 -0
  72. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/bits.py +0 -0
  73. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/canopen.py +0 -0
  74. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/codegen/__init__.py +0 -0
  75. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/codegen/c.py +0 -0
  76. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/codegen/common.py +0 -0
  77. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/codegen/py.py +0 -0
  78. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/codegen/zephyr.py +0 -0
  79. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/diagnostics.py +0 -0
  80. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/diff.py +0 -0
  81. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/docmodel.py +0 -0
  82. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/export/__init__.py +0 -0
  83. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/export/dbc.py +0 -0
  84. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/export/eds.py +0 -0
  85. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/export/pdf.py +0 -0
  86. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/export/text.py +0 -0
  87. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/export/xlsx.py +0 -0
  88. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/extras.py +0 -0
  89. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/OFL-PTSerif.txt +0 -0
  90. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/OFL.txt +0 -0
  91. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-Bold.ttf +0 -0
  92. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-BoldItalic.ttf +0 -0
  93. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-Italic.ttf +0 -0
  94. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-Regular.ttf +0 -0
  95. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-Bold.ttf +0 -0
  96. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-BoldItalic.ttf +0 -0
  97. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-Italic.ttf +0 -0
  98. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-Regular.ttf +0 -0
  99. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/i18n.py +0 -0
  100. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/importers/__init__.py +0 -0
  101. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/importers/dbc.py +0 -0
  102. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/layout.py +0 -0
  103. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/loader.py +0 -0
  104. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/locale/__init__.py +0 -0
  105. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/lsp/__init__.py +0 -0
  106. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/lsp/exports.py +0 -0
  107. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/model.py +0 -0
  108. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/protocols.py +0 -0
  109. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/resolve.py +0 -0
  110. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/schema.py +0 -0
  111. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/document.html.j2 +0 -0
  112. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/document.md.j2 +0 -0
  113. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/pdf/gost-19.typ +0 -0
  114. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/pdf/gost-2.105.typ +0 -0
  115. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/pdf/gost-7.32.typ +0 -0
  116. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/pdf/lib.typ +0 -0
  117. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/pdf/mapcan.typ +0 -0
  118. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/templates/xlsx_style.yaml +0 -0
  119. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/units.py +0 -0
  120. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/updates.py +0 -0
  121. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/upgrade.py +0 -0
  122. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/userconfig.py +0 -0
  123. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/__init__.py +0 -0
  124. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/examples.py +0 -0
  125. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/newmap.py +0 -0
  126. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/static/OFL-Sora.txt +0 -0
  127. {mapcan-0.23.0 → mapcan-0.23.2}/src/mapcan/web/static/favicon.svg +0 -0
  128. {mapcan-0.23.0 → mapcan-0.23.2}/tests/__init__.py +0 -0
  129. {mapcan-0.23.0 → mapcan-0.23.2}/tests/conftest.py +0 -0
  130. {mapcan-0.23.0 → mapcan-0.23.2}/tests/data/invalid_map.yaml +0 -0
  131. {mapcan-0.23.0 → mapcan-0.23.2}/tests/data/legacy.yaml +0 -0
  132. {mapcan-0.23.0 → mapcan-0.23.2}/tests/data/logo.png +0 -0
  133. {mapcan-0.23.0 → mapcan-0.23.2}/tests/data/logo.svg +0 -0
  134. {mapcan-0.23.0 → mapcan-0.23.2}/tests/data/reference.yaml +0 -0
  135. {mapcan-0.23.0 → mapcan-0.23.2}/tests/data/zephyr_stub/zephyr/drivers/can.h +0 -0
  136. {mapcan-0.23.0 → mapcan-0.23.2}/tests/data/zephyr_stub/zephyr/kernel.h +0 -0
  137. {mapcan-0.23.0 → mapcan-0.23.2}/tests/golden/reference.md +0 -0
  138. {mapcan-0.23.0 → mapcan-0.23.2}/tests/golden/reference_bus1.h +0 -0
  139. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_auth.py +0 -0
  140. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_cli.py +0 -0
  141. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_codegen.py +0 -0
  142. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_diff.py +0 -0
  143. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_direction.py +0 -0
  144. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_exchange.py +0 -0
  145. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_export.py +0 -0
  146. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_extras.py +0 -0
  147. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_i18n.py +0 -0
  148. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_import.py +0 -0
  149. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_j1939.py +0 -0
  150. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_launcher.py +0 -0
  151. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_loader_fast.py +0 -0
  152. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_model.py +0 -0
  153. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_new_signal.py +0 -0
  154. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_source.py +0 -0
  155. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_templates.py +0 -0
  156. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_titles.py +0 -0
  157. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_units.py +0 -0
  158. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_upgrade.py +0 -0
  159. {mapcan-0.23.0 → mapcan-0.23.2}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mapcan
3
- Version: 0.23.0
3
+ Version: 0.23.2
4
4
  Summary: A YAML map of CAN signals as the single source of truth: validation, Excel/PDF/Markdown/HTML, DBC, C/Python code
5
5
  Project-URL: Homepage, https://github.com/ordu1453/mapcan
6
6
  Project-URL: Documentation, https://github.com/ordu1453/mapcan/blob/main/docs/format.md
@@ -180,7 +180,7 @@ CANopen ID computation:
180
180
  | `terminator` | `string`: the byte that ends a shorter text and fills the rest of the field: a number (`0x00`) or one character (`"*"`, as in J1939 text parameters). Without it the text fills the field and unused bytes are 0x00 |
181
181
  | `start_bit` + `bit_length` | Canonical position |
182
182
  | `byte` [+ `bit`] + `byte_length` \| `bit_length` | Shorthand position (see below) |
183
- | `byte_order` | `little_endian` (default) or `big_endian` |
183
+ | `byte_order` | `little_endian` (default) or `big_endian`. CANopen and J1939 carry little-endian data, so the editors offer the field only on other buses (or when it is set), and validation warns about a big-endian signal there (`byte-order-protocol`) |
184
184
  | `representation` | How the raw value is read as a number: `unsigned` (default), `twos_complement` (the usual signed integer), `ones_complement` or `sign_magnitude`. Generated C and Python code packs and unpacks the value in this representation; the last two have a symmetric range (−127…127 for 8 bits). DBC stores two's complement only, so the export warns about the other two. Formerly `signed` + `signed_code` |
185
185
  | `values` | Value table: `{0: NO_DATA, 1: {name: OK, description: "normal"}}` |
186
186
  | `value_format` | `dec` \| `hex`: how values are shown in documents |
@@ -302,6 +302,13 @@ marked as float.
302
302
  `start_bit` cannot be combined with `byte`/`bit`, and `bit_length` cannot be combined with
303
303
  `byte_length`.
304
304
 
305
+ Both notations are valid. The editors (the forms and bit layouts of the web editor and VS Code,
306
+ `mapcan import dbc`) write one of them, whichever field or drag changed the position: the shorthand
307
+ when the signal takes whole bytes from a byte boundary (`byte` + `byte_length`) or fits in one byte
308
+ (`byte` + `bit` + `bit_length`), and `start_bit` + `bit_length` otherwise. A length the encoding
309
+ implies (32 bits of a `float`, 1 bit of a `flag`) and `bit: 0` stay omitted where they were.
310
+ `mapcan fmt --check` notes the positions written otherwise, without failing.
311
+
305
312
  #### Big-endian
306
313
 
307
314
  For `big_endian` (Motorola) signals, `start_bit` is also the position of the least significant
@@ -567,6 +574,13 @@ file (or an earlier included file) already describes can only get more `devices`
567
574
  its other fields, and a value table name used twice, are errors (`include`). Included files are
568
575
  not nested.
569
576
 
577
+ To keep one file per device, add a device with **Add Device in a New File…** on a bus in the
578
+ VS Code Map view, or **+ Device in a new file** on a bus page of the web editor: the editor makes
579
+ the file (by default `devices/<name>.yaml`) with the device, named after the file when that name
580
+ is a free identifier on the bus, and adds it to `include`, in one edit. Deleting a device from the
581
+ editors' trees deletes an included file it leaves adding nothing to the map (only the bus by name,
582
+ without devices, frames or value tables), with its line of `include`; Undo brings both back.
583
+
570
584
  Validation, documents and generated code use the whole map. Messages about an included file
571
585
  point into that file on the command line; in the editors they are shown on the `include` line
572
586
  of the main file, with the file and line in the text. The editors' trees show the included buses,
@@ -610,7 +624,8 @@ Included devices can be chosen as senders and receivers of the main file's frame
610
624
  The same map can be written with the keys in any order. `mapcan fmt FILE…` writes the keys of every
611
625
  node in one order, so that diffs of maps edited by different people and editors show only what changed;
612
626
  `mapcan fmt --check` exits 1 if a file is not in that order (for CI and a pre-commit hook). Included
613
- files are formatted with their map.
627
+ files are formatted with their map. `--check` also notes signal positions written in another notation
628
+ than the editors write (see [Shorthand position](#shorthand-position)); they do not fail the check.
614
629
 
615
630
  Only the order changes. Comments move with their key (a comment line just above a key belongs to it),
616
631
  and the author's choices stay: block or flow style (`{name: …}`), the position notation (`start_bit`
@@ -684,6 +699,7 @@ turned off with `--disable CODE` or `lint.disable`.
684
699
  | `dlc-missing` | error | Frame has no `length` (only CANopen service frames may omit it) |
685
700
  | `canopen-dlc` | warning | Length of a CANopen service frame does not match CiA 301 |
686
701
  | `frame-type-protocol` | warning | `canopen` set on a frame of a non-CANopen bus |
702
+ | `byte-order-protocol` | warning | A big-endian signal spanning bytes on a CANopen or J1939 bus: both carry little-endian data (CiA 301, SAE J1939-71) |
687
703
  | `direction-deprecated` | warning | `direction` is set: it follows from the CANopen function and `sender`; a frame from outside the map goes to the frames of its bus. `mapcan migrate` does both |
688
704
  | `direction-mismatch` | warning | Direction conflicts with the frame's CANopen function |
689
705
 
@@ -697,7 +713,7 @@ turned off with `--disable CODE` or `lint.disable`.
697
713
  | `j1939-field-protocol` | warning | pgn/priority/destination/address/spn on a non-J1939 bus |
698
714
  | `j1939-spn-duplicate` | warning | The same SPN on several signals of the bus |
699
715
  | `j1939-tp` | info | A PGN longer than 8 bytes is sent with the J1939-21 transport protocol |
700
- | `canopen-od` | warning | Dictionary object on a signal outside a device's PDO, outside 0x2000..0x9FFF, or big-endian |
716
+ | `canopen-od` | warning | Dictionary object on a signal outside a device's PDO, or outside 0x2000..0x9FFF |
701
717
  | `od-duplicate` | warning | The same dictionary object on several signals of a device |
702
718
 
703
719
  ### Sender and receivers
@@ -49,6 +49,8 @@ offers to save them before switching.
49
49
 
50
50
  The start screen also lists the bundled examples: pick one, choose where to save a copy, and it
51
51
  opens. The mapcan version is shown in the top bar next to the title and on the start screen.
52
+ **File → Report an issue** (and the link on the start screen) opens a new GitHub issue with the
53
+ versions of mapcan, the server's system and the browser filled in; the map itself is not sent.
52
54
 
53
55
  ### Security
54
56
 
@@ -110,13 +112,14 @@ The profile records who made which changes:
110
112
  |---|---|
111
113
  | **Tree** (left) | Document → buses → devices → frames → signals → flags and SDO exchanges. Nodes are labelled with their human-readable names (`title`), not identifiers: a device shows its abbreviation, if set, and its full name (`BMS · Battery management system`); a frame or signal without a name falls back to its identifier. Double-click a name (or a device abbreviation) to edit it. Buses and devices must have a full name: a new one gets a default name, and its name field opens in the tree right away so you can type the real one; a map without it reports `missing-title`. Frames show their CAN ID, signals the bits they occupy. A dot marks an error or warning in the node itself, a ring marks one in its children. Under a device, frames of other devices that it receives follow its own frames as links (↪, in italics), labelled with the sender (its short name if set) and the frame name; clicking one opens the original frame. **Delete** on such a link removes the device from the frame's receivers and keeps the frame itself. |
112
114
  | **Search** | By name, description or CAN ID (`0x196` or `406`). With several words, all of them must match. |
113
- | **Properties** | The form for the selected node. A value is applied on Enter or when the field loses focus. Validation messages appear under the field. The form shows only the fields that apply to the node: a frame on a CANopen bus hides **Direction** when its type sets it, an RX frame has no **Receivers** (the device itself receives it), a TX frame has no **Sender**, a bus frame has no **Direction**, and **Sender** and **Receivers** appear only when there are other devices on the bus. A CANopen service frame (NMT, Heartbeat, TIME, SDO) has no **Length** field: its length comes from its CANopen function. A field that is set in the YAML or has a validation message stays visible so it can be fixed. Elsewhere in the editor (page headings, breadcrumbs, frame, device and bus lists, sender and receivers) objects are shown by their names, with the identifier next to them in grey. Text fields (names, descriptions) are edited in the document language; translations into the other language written in the YAML are kept. The **?** next to a field label explains the setting, why it matters and which values are allowed (shown on hover; click to pin, Esc to close). |
115
+ | **Properties** | The form for the selected node. A value is applied on Enter or when the field loses focus. Validation messages appear under the field. The form shows only the fields that apply to the node: a frame on a CANopen bus hides **Direction** when its type sets it, an RX frame has no **Receivers** (the device itself receives it), and **Receivers** appear only when there are other devices on the bus. There is no field for the sender: it follows from where the frame is written (its device, or outside the map for a frame of the bus); the master that sends a CANopen RX function written at its node stays `sender` in the YAML. A CANopen service frame (NMT, Heartbeat, TIME, SDO) has no **Length** field: its length comes from its CANopen function. A field that is set in the YAML or has a validation message stays visible so it can be fixed. Elsewhere in the editor (page headings, breadcrumbs, frame, device and bus lists, sender and receivers) objects are shown by their names, with the identifier next to them in grey. Text fields (names, descriptions) are edited in the document language; translations into the other language written in the YAML are kept. The **?** next to a field label explains the setting, why it matters and which values are allowed (shown on hover; click to pin, Esc to close). |
114
116
  | **Frame layout** | The bit map of the frame: rows are bytes, columns are bits 7…0. Cells are labelled with signal names and the flags of bit fields. |
115
117
  | **Frames** | The frame list on a bus or device page; ✕ in a row deletes the frame. **+ Frame** adds a frame to the list without changing the selected node. |
116
- | **Receives** | On a device page: the bus frames the device receives (its own RX frames and other devices' frames that list it as a receiver), with their senders. Below it, **Transmits frames of other devices** lists the frames it sends on behalf of other devices (`sender`). Both are computed from the frames; click a row to jump to the frame. On a frame page, **Sender** is picked from the bus devices and **Receivers** are toggled on and off; renaming a device updates both. ✕ in a row of another device's frame removes the device from its receivers. On an RX frame, **Frame of another device** picks a frame that is already described at its sender: the device is added to that frame's receivers and its own copy is removed (Ctrl+Z undoes it). The `rx-duplicate` message on such a frame has a button that does the same. |
118
+ | **Devices** | The device list on a bus page. **+ Device** adds a device to the file that describes the bus. **+ Device in a new file** asks for a file, `devices/<name>.yaml` relative to the map by default, makes it with the device (`mapcan: 1`, `part_of` and the bus by name; the device is named after the file when that name is a free identifier on the bus) and adds it to `include` of the map: one undo step, and **Save** writes both files. A map edited by several people can keep one file per device this way, so changes to different devices do not meet in one file. Deleting such a device deletes its file, left empty, and its line of `include` (on Save; Ctrl+Z brings them back). |
119
+ | **Receives** | On a device page: the bus frames the device receives (its own RX frames and other devices' frames that list it as a receiver), with their senders. Below it, **Transmits frames of other devices** lists the frames it sends on behalf of other devices (`sender`). Both are computed from the frames; click a row to jump to the frame. On a frame page, **Receivers** are toggled on and off; renaming a device updates them and `sender`. ✕ in a row of another device's frame removes the device from its receivers. On an RX frame, **Frame of another device** picks a frame that is already described at its sender: the device is added to that frame's receivers and its own copy is removed (Ctrl+Z undoes it). The `rx-duplicate` message on such a frame has a button that does the same. |
117
120
  | **Values / Flags** | Enum and bit field tables: value (decimal or `0x…`), name, description. **+ Flag** adds a row without leaving the signal page. |
118
121
  | **Validation** (bottom) | The same rules as `mapcan validate`. Click a message to jump to the node (or, on the YAML tab, to the line). While the YAML tab holds text that is not applied yet (for example, with a syntax error), the panel lists the messages for that text and marks them **YAML text not applied**. |
119
- | **YAML** | Edit the whole file as text. The text is validated as you type: lines with problems are marked in the gutter (hover a line number to read the message), and the first syntax error is shown in the YAML toolbar; click it to jump to its line. Once the text parses, it is applied automatically after a short pause, so the tree and the editor follow your typing; a whole run of typing is one undo step. Switching to the editor applies what is left (it stays on the YAML tab if there is a syntax error). Renaming a device in the text also updates `sender` and `receivers` of the bus frames, as renaming in the form does. Tab and Shift+Tab indent and outdent the selected lines, Enter keeps the indentation (one level deeper after `key:`), Backspace after spaces goes back to the previous tab stop (2 spaces); press Esc first to move focus with Tab. Ctrl+Enter applies the edit at once. While you type a key, a list offers the fields allowed at that place that are not set yet (required ones first), with the field description from the format schema; after `key:` it offers the values of enumerations (`encoding`, `protocol`, `byte_order`, `true`/`false`…). The same works inside a one-line `{...}` mapping (after `{` and after `, `), and a new `- ` list item also offers `{ }` to write the item on one line. Arrows choose, Enter or Tab inserts (a list or object field also opens its first level), Esc closes, Ctrl+Space shows the list on an empty line. |
122
+ | **YAML** | Edit the whole file as text. The text is validated as you type: lines with problems are marked in the gutter (hover a line number to read the message), and the first syntax error is shown in the YAML toolbar; click it to jump to its line. Once the text parses, it is applied automatically after a short pause, so the tree and the editor follow your typing; a whole run of typing is one undo step. Switching to the editor applies what is left (it stays on the YAML tab if there is a syntax error). Renaming a device in the text also updates `sender` and `receivers` of the bus frames, as renaming in the form does. Tab and Shift+Tab indent and outdent the selected lines, Enter keeps the indentation (one level deeper after `key:`), Backspace after spaces goes back to the previous tab stop (2 spaces); press Esc first to move focus with Tab. Ctrl+Enter applies the edit at once. While you type a key, a list offers the fields allowed at that place that are not set yet (required ones first), with the field description from the format schema; after `key:` it offers the values of enumerations (`encoding`, `protocol`, `byte_order`, `true`/`false`…). The same works inside a one-line `{...}` mapping (after `{` and after `, `), and a new `- ` list item also offers `{ }` to write the item on one line. Arrows choose, Enter or Tab inserts (a list or object field also opens its first level), Esc closes, Ctrl+Space shows the list on an empty line. A map with included files (`include`) shows one file at a time: a node selected in the tree opens the file it is written in (a device of `devices/node.yaml`, a frame where its sender describes it), with the node's lines highlighted, and the list in the YAML toolbar switches between the map and its files (• marks a file with unsaved edits). The text of an included file is edited and applied the same way and goes to that file; **Save** writes it. On the editor tab, **Open the YAML of …** in the note above a node of an included file opens it. |
120
123
  | **Export** | Documents (PDF, HTML, Markdown, Excel) and development formats (DBC, C, Python), generated from the current state, including unsaved changes. Clicking a document opens a preview; for PDF, the template is chosen there: `mapcan` or ESKD (see [templates.md](templates.md)). ↓ on the right downloads the file directly. The document language (English or Russian; the map's language by default) is chosen in the menu and in the preview. The ESKD template is always in Russian. |
121
124
  | **Layout and document details** | In the document properties: logo (file upload), designation, letter, organization, city, title block signatures, approval blocks. |
122
125
  | **Git → Compare versions…** | Diffs the current map against a git revision (HEAD, a tag or a commit) or an uploaded file: changes to frames, signals, values and flags. **Add to history** writes the changelog into `document.history` and bumps the version. |
@@ -177,8 +180,9 @@ available at `http://127.0.0.1:8765/api/docs`.
177
180
  | POST | `/api/file/open`, `/api/file/new`, `/api/file/example`, `/api/file/save_as`, `/api/file/close` | File operations; `409 unsaved` means there are unsaved changes (pass `force: true` to proceed) |
178
181
  | GET | `/api/state` | Text, data, computed layout and diagnostics |
179
182
  | POST | `/api/ops` | `{"ops": [...]}`: apply operations atomically |
180
- | POST | `/api/text` | Replace the YAML text |
181
- | POST | `/api/check` | `{"text": ...}`: validate text without applying it; returns `{diagnostics}` |
183
+ | POST | `/api/device_file` | `{"bus": ["buses", 0], "device": {...}, "file": "devices/node.yaml"}`: a new device in a new file, added to `include` |
184
+ | POST | `/api/text` | `{"text": ..., "file": null}`: replace the YAML text of the map, or of an included file (`file`, relative to the map) |
185
+ | POST | `/api/check` | `{"text": ..., "file": null}`: validate text without applying it; returns `{diagnostics}` (a message about an included file has `part`: its file, line and column) |
182
186
  | POST | `/api/undo`, `/api/redo` | Edit history |
183
187
  | POST | `/api/save` | `{"force": false}`; `409` means the file changed on disk |
184
188
  | POST | `/api/reload` | Reload the file from disk |
@@ -66,12 +66,11 @@ buses:
66
66
  offset: -20
67
67
  unit: °C
68
68
 
69
- - name: speed # big-endian: byte 5 is the high byte, byte 6 the low byte
69
+ - name: speed # two bytes, little-endian (CANopen): byte 5 is the low byte
70
70
  description: Speed
71
71
  encoding: scaled
72
72
  byte: 5
73
73
  byte_length: 2
74
- byte_order: big_endian
75
74
  unit: rpm
76
75
 
77
76
  - name: counter # arbitrary position: bits 56..59
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mapcan"
3
- version = "0.23.0"
3
+ version = "0.23.2"
4
4
  description = "A YAML map of CAN signals as the single source of truth: validation, Excel/PDF/Markdown/HTML, DBC, C/Python code"
5
5
  # readme: built from README.md with absolute links for PyPI (see fancy-pypi-readme below)
6
6
  dynamic = ["readme"]
@@ -1,6 +1,6 @@
1
1
  """mapcan: a YAML signal map as the single source of truth."""
2
2
 
3
- __version__ = "0.23.0"
3
+ __version__ = "0.23.2"
4
4
 
5
5
  from .loader import load, loads # noqa: E402
6
6
  from .model import MapCan # noqa: E402
@@ -261,7 +261,7 @@ def fmt(
261
261
  paths: list[Path] = typer.Argument(..., exists=True, dir_okay=False, help=_("Map files.")),
262
262
  check: bool = typer.Option(False, "--check", help=_("Do not write; exit code 1 if a file is not formatted.")),
263
263
  ) -> None:
264
- from .fmt import FmtError, format_text
264
+ from .fmt import FmtError, format_text, position_notes
265
265
 
266
266
  unformatted, failed = [], False
267
267
  for f in _with_included(paths):
@@ -272,6 +272,11 @@ def fmt(
272
272
  typer.secho(f"{f}: {e}", fg=typer.colors.RED, err=True)
273
273
  failed = True
274
274
  continue
275
+ if check:
276
+ # Positions in another notation than the editors write (#117): a note, the map stays valid
277
+ for line, name, keys in position_notes(text):
278
+ typer.echo(_("{file}:{line}: note: signal {name!r}: the editors write its position as {keys}",
279
+ file=f, line=line, name=name, keys=keys))
275
280
  if new == text:
276
281
  continue
277
282
  unformatted.append(f)
@@ -315,3 +315,31 @@ def format_text(text: str) -> str:
315
315
  or new.count("#") != text.count("#")):
316
316
  raise FmtError(_("cannot format: the result would read differently; the file is left as it is"))
317
317
  return new
318
+
319
+
320
+ def position_notes(text: str) -> list[tuple[int, str, str]]:
321
+ """Signals whose position is not written in the canonical notation the editors use (#117): (line from 1, signal,
322
+ the canonical keys as YAML). ``mapcan fmt`` does not rewrite them: they are reported by ``--check`` only."""
323
+ from .web.yamlops import canonical_position
324
+
325
+ try:
326
+ doc = make_yaml(text).load(text)
327
+ except Exception: # noqa: BLE001 — not YAML: format_text reports it
328
+ return []
329
+ out = []
330
+
331
+ def frames(node: Any) -> list:
332
+ items = node.get("frames") if isinstance(node, CommentedMap) else None
333
+ return items if isinstance(items, CommentedSeq) else []
334
+
335
+ buses = doc.get("buses") if isinstance(doc, CommentedMap) else None
336
+ for bus in buses if isinstance(buses, CommentedSeq) else []:
337
+ devices = bus.get("devices") if isinstance(bus, CommentedMap) else None
338
+ owners = [bus, *(devices if isinstance(devices, CommentedSeq) else [])]
339
+ for frame in (f for o in owners for f in frames(o)):
340
+ signals = frame.get("signals") if isinstance(frame, CommentedMap) else None
341
+ for sig in signals if isinstance(signals, CommentedSeq) else []:
342
+ if (keys := canonical_position(sig)) is not None:
343
+ written = ", ".join(f"{k}: {v}" for k, v in keys.items())
344
+ out.append((sig.lc.line + 1, str(sig.get("name", "?")), written))
345
+ return out
@@ -252,7 +252,7 @@ def run(path: Path | None = None, *, window: bool = True, readonly: bool = False
252
252
 
253
253
  app = create_app(session, cwd=cwd.resolve(), readonly=readonly, check_updates=check_updates,
254
254
  instance_token=token if single_instance else None, on_activate=on_activate,
255
- allowed_hosts=LOCAL_HOSTS)
255
+ allowed_hosts=LOCAL_HOSTS, mode="app")
256
256
  server = ServerThread(app, host, port)
257
257
  try:
258
258
  server.start()
@@ -234,6 +234,8 @@ RU: dict[str, str] = {
234
234
  "direction кадра {name!r} противоречит его функции CANopen или отправителю: удалите его и укажите в sender "
235
235
  "устройство, которое передаёт кадр",
236
236
  "CANopen frame type on a non-CANopen bus": "Тип кадра CANopen на шине не CANopen",
237
+ "Big-endian signal on a CANopen or J1939 bus (they carry little-endian data)":
238
+ "Сигнал big-endian на шине CANopen или J1939 (они передают данные little-endian)",
237
239
  "sdo description on a frame that is not sdo_tx/sdo_rx": "Описание sdo у кадра не типа sdo_tx/sdo_rx",
238
240
  "Duplicate SDO exchange (command, index, subindex)": "Повтор SDO-обмена (команда, индекс, субиндекс)",
239
241
  "Name does not match the style rule (lint)": "Имя не соответствует правилу стиля (lint)",
@@ -356,6 +358,16 @@ RU: dict[str, str] = {
356
358
  "short label {short!r} is already used by device {other!r}": "обозначение {short!r} уже у устройства {other!r}",
357
359
  "frame {name!r} differs from {other!r} only in case: their C macros will collide":
358
360
  "кадр {name!r} отличается от {other!r} только регистром: в C-коде их макросы совпадут",
361
+ "Set the bitrate of the bus to estimate its load.": "Задайте скорость шины (bitrate), чтобы оценить её загрузку.",
362
+ "Set data_bitrate to estimate the load: frames with bit rate switching send their data at it.":
363
+ "Задайте data_bitrate, чтобы оценить загрузку: кадры с переключением скорости передают данные на ней.",
364
+ "Bus load unknown: no frame has a cycle or a minimum interval.":
365
+ "Загрузка неизвестна: ни у одного кадра нет периода или минимального интервала.",
366
+ "Estimated bus load: {load}": "Расчётная загрузка шины: {load}",
367
+ "At least: {counted} of {total} frames are counted; frames without a cycle or a minimum interval are not.":
368
+ "Не меньше: учтено кадров {counted} из {total}; кадры без периода и минимального интервала не учитываются.",
369
+ "Frames sent on an event are counted at their minimum interval, with worst-case bit stuffing.":
370
+ "Кадры по событию учитываются с минимальным интервалом, bit stuffing — по худшему случаю.",
359
371
  "estimated load of bus {bus!r} is {load:.0f}% (frames counted: {count}, {bitrate} bit/s; event frames at their "
360
372
  "minimum interval); keep it at or below {limit}%":
361
373
  "расчётная загрузка шины {bus!r} — {load:.0f} % (учтено кадров: {count}, {bitrate} бит/с; кадры по событию — "
@@ -470,7 +482,7 @@ RU: dict[str, str] = {
470
482
  "{name!r}: subindex without index": "{name!r}: subindex без index",
471
483
  "{name!r}: index {index:#06x} is outside the manufacturer and profile areas (0x2000..0x9FFF)":
472
484
  "{name!r}: индекс {index:#06x} вне областей производителя и профилей (0x2000..0x9FFF)",
473
- "{name!r}: CANopen PDOs carry little-endian data": "{name!r}: PDO CANopen передаёт данные little-endian",
485
+ "{name!r}: {protocol} carries little-endian data": "{name!r}: {protocol} передаёт данные little-endian",
474
486
  "object {index:04X}:{sub:02X} is already mapped by {other}": "объект {index:04X}:{sub:02X} уже у сигнала {other}",
475
487
  "CANopen: index of the dictionary object mapped into the PDO.":
476
488
  "CANopen: индекс объекта словаря, отображённого в PDO.",
@@ -602,6 +614,12 @@ RU: dict[str, str] = {
602
614
  "could not save settings: {error}": "не удалось сохранить настройки: {error}",
603
615
  "file not found: {path}": "файл не найден: {path}",
604
616
  "file already exists: {path}": "файл уже существует: {path}",
617
+ "Enter the name of the file": "Введите имя файла",
618
+ "the name of an included file must end with .yaml or .yml":
619
+ "имя включаемого файла должно оканчиваться на .yaml или .yml",
620
+ "the path of an included file is relative to the map": "путь включаемого файла указывается относительно карты",
621
+ "include of the map is not a list": "include карты — не список",
622
+ "Save the map to a file first.": "Сначала сохраните карту в файл.",
605
623
  "{fmt} export error: {error}": "ошибка экспорта {fmt}: {error}",
606
624
  "the file is not UTF-8 encoded": "файл не в UTF-8",
607
625
  "could not read {name}: {error}": "не удалось прочитать {name}: {error}",
@@ -672,6 +690,10 @@ RU: dict[str, str] = {
672
690
  "Bit": "Бит",
673
691
  "Flag": "Флаг",
674
692
  "Byte": "Байт",
693
+ "Signal": "Сигнал",
694
+ "Position": "Положение",
695
+ "Size": "Размер",
696
+ "bit": "бит",
675
697
  "Export canceled: the map has errors ({errors}). Fix them and try again.":
676
698
  "Экспорт отменён: в карте есть ошибки ({errors}). Исправьте их и повторите.",
677
699
  "Save the map to a file before exporting.": "Сохраните карту в файл перед экспортом.",
@@ -866,6 +888,8 @@ RU: dict[str, str] = {
866
888
  "Do not write; exit code 1 if a file is not formatted.":
867
889
  "Не записывать; код выхода 1, если файл не отформатирован.",
868
890
  "{file}: not formatted, run mapcan fmt": "{file}: не отформатирован, выполните mapcan fmt",
891
+ "{file}:{line}: note: signal {name!r}: the editors write its position as {keys}":
892
+ "{file}:{line}: примечание: сигнал {name!r}: редакторы записывают его положение как {keys}",
869
893
  "part_of: the map {map!r} is not found": "part_of: карта {map!r} не найдена",
870
894
  "part_of: the map {map!r} does not include this file": "part_of: карта {map!r} не включает этот файл",
871
895
  "the map {map!r} has {n} error(s) outside this file: open it to see them":
@@ -21,7 +21,7 @@ from ..docmodel import fmt_num
21
21
  from ..i18n import _, normalize_language
22
22
  from ..loader import PART_OF_RE, LoadResult, SourceMap, loads, paused_gc
23
23
  from ..model import MapCan, enum_entries
24
- from ..validate import bus_load_text, check
24
+ from ..validate import bus_load_note, bus_load_text, check
25
25
 
26
26
  Pos = tuple[int, int] # 0-based (line, column)
27
27
  Path_ = tuple
@@ -446,7 +446,8 @@ def device_label(d: dict, lang: str) -> str:
446
446
 
447
447
 
448
448
  def map_tree(a: Analysis) -> list[dict]:
449
- """Nodes: kind, label, ident, description, dir (frames), from (received frames), range, selection, children.
449
+ """Nodes: kind, label, ident, description, dir (frames), from (received frames), to (frames the device sends,
450
+ described at their receiver), range, selection, children.
450
451
 
451
452
  Labels are as in the web editor's tree: titles in the document language, the identifier (``ident``)
452
453
  only if there is no title. Built from the raw YAML; computed IDs and bits come from the model if the map parses.
@@ -484,7 +485,7 @@ def map_tree(a: Analysis) -> list[dict]:
484
485
  def model(path: Path_):
485
486
  return _find(a.map, path) if a.map is not None else None
486
487
 
487
- def frame_node(f: dict, path: Path_) -> dict | None:
488
+ def frame_node(f: dict, path: Path_, devices: list) -> dict | None:
488
489
  loc = rng(path)
489
490
  if loc is None:
490
491
  return None
@@ -494,7 +495,12 @@ def map_tree(a: Analysis) -> list[dict]:
494
495
  sub = f"PGN {_hexw(f['pgn'], 4)}"
495
496
  elif isinstance(rf, M.Frame) and rf.can_id is not None:
496
497
  sub = _hex(rf.can_id, rf.extended)
497
- node = {"kind": "frame", "label": display_name(f, lang), "ident": name_of(f), "description": sub,
498
+ label = display_name(f, lang)
499
+ # A frame described at its receiver (sender: another device): the label names the sender, as a link does
500
+ if isinstance(rf, M.Frame) and rf.device is not None and rf.sender_name not in (None, rf.device.name):
501
+ sender = next((x for x in devices if x.get("name") == rf.sender_name), None)
502
+ label = f"{device_label(sender, lang) if sender else rf.sender_name} · {label}"
503
+ node = {"kind": "frame", "label": label, "ident": name_of(f), "description": sub,
498
504
  "dir": rf.direction if isinstance(rf, M.Frame) and rf.device is not None else None,
499
505
  "children": [], **loc}
500
506
  for si, s in enumerate(_items(f.get("signals"))):
@@ -528,13 +534,15 @@ def map_tree(a: Analysis) -> list[dict]:
528
534
  if (bl := rng(bp)) is None:
529
535
  continue
530
536
  mbus = model(bp)
531
- # The estimated bus load ("≥" when frames without a known period are not counted)
537
+ # The estimated bus load ("≥" when frames without a known period are not counted), and what it is
538
+ is_bus = isinstance(mbus, M.Bus)
532
539
  bnode = {"kind": "bus", "label": display_name(bus, lang), "ident": name_of(bus),
533
- "description": bus_load_text(mbus) if isinstance(mbus, M.Bus) else "", "children": [], **bl}
540
+ "description": bus_load_text(mbus) if is_bus else "",
541
+ "load_note": bus_load_note(mbus) if is_bus else "", "children": [], **bl}
542
+ devices = _items(bus.get("devices"))
534
543
  for fi, f in enumerate(_items(bus.get("frames"))):
535
- if (fn := frame_node(f, bp + ("frames", fi))) is not None:
544
+ if (fn := frame_node(f, bp + ("frames", fi), devices)) is not None:
536
545
  bnode["children"].append(fn)
537
- devices = _items(bus.get("devices"))
538
546
  for di, d in enumerate(devices):
539
547
  dp = bp + ("devices", di)
540
548
  if (dl := rng(dp)) is None:
@@ -544,7 +552,7 @@ def map_tree(a: Analysis) -> list[dict]:
544
552
  dnode = {"kind": "device", "label": display_name(d, lang), "ident": name_of(d), "description": sub,
545
553
  "children": [], **dl}
546
554
  for fi, f in enumerate(_items(d.get("frames"))):
547
- if (fn := frame_node(f, dp + ("frames", fi))) is not None:
555
+ if (fn := frame_node(f, dp + ("frames", fi), devices)) is not None:
548
556
  dnode["children"].append(fn)
549
557
  # Frames of other devices that the device receives: links after its own; the frame's title may
550
558
  # not tell whose it is, so the link's label names the sender
@@ -561,6 +569,20 @@ def map_tree(a: Analysis) -> list[dict]:
561
569
  "ident": rf.name, "dir": "rx", "from": sender_label or rf.sender_name or "",
562
570
  "description": _hex(rf.can_id, rf.extended) if rf.can_id is not None else "",
563
571
  "children": [], **loc})
572
+ # Frames the device sends that are described at their receiver (sender: this device): links too, the
573
+ # label names the device they are described at
574
+ for rf in mbus.all_frames() if isinstance(mbus, M.Bus) else []:
575
+ if (rf.device is None or rf.device.name == d.get("name") or rf.sender_name != d.get("name")
576
+ or (loc := rng(rf._path)) is None):
577
+ continue
578
+ owner = next((x for x in devices if x.get("name") == rf.device.name), None)
579
+ owner_label = device_label(owner, lang) if owner else rf.device.name
580
+ frame = raw_at_path(raw, rf._path)
581
+ title = display_name(frame if isinstance(frame, dict) else {"name": rf.name}, lang)
582
+ dnode["children"].append({
583
+ "kind": "frame", "label": f"{owner_label} · {title}", "ident": rf.name, "dir": "tx",
584
+ "to": owner_label, "description": _hex(rf.can_id, rf.extended) if rf.can_id is not None else "",
585
+ "children": [], **loc})
564
586
  bnode["children"].append(dnode)
565
587
  out.append(bnode)
566
588
  return out
@@ -765,7 +787,12 @@ def frame_markdown(frame: M.Frame) -> str:
765
787
 
766
788
 
767
789
  def frame_layout(frame: M.Frame) -> str:
768
- """Bit map of a frame as a markdown table: rows are bytes, columns are bits 7…0."""
790
+ """The signals of a frame and its bit map as markdown tables.
791
+
792
+ The list of signals comes first: a hover shows the top of a long text, and the numbers in the map mean nothing
793
+ without it. Rows of the map are bytes, columns are bits 7…0; consecutive bytes with the same owners are one row,
794
+ so a frame of 64 bytes stays short.
795
+ """
769
796
  sigs = frame.layout
770
797
  if not sigs or frame.length == 0:
771
798
  return ""
@@ -773,13 +800,21 @@ def frame_layout(frame: M.Frame) -> str:
773
800
  for i, s in enumerate(sigs, 1):
774
801
  for b in occupied_bits(s.start_bit or 0, s.bit_length or 1, s.byte_order == "big_endian"):
775
802
  cells.setdefault(b, []).append(i)
803
+ legend = ["| # | " + _("Signal") + " | " + _("Position") + " | " + _("Size") + " |", "|---:|---|---|---:|"]
804
+ legend += [f"| {i} | `{s.name}` | {signal_bits(s)} | {s.bit_length or 1} " + _("bit") + " |"
805
+ for i, s in enumerate(sigs, 1)]
776
806
  rows = ["| " + _("Byte") + " | " + " | ".join(str(b) for b in range(7, -1, -1)) + " |",
777
807
  "|---:|" + ":-:|" * 8]
778
- for byte in range(frame.length):
779
- row = [",".join(map(str, cells.get(byte * 8 + bit, []))) or "·" for bit in range(7, -1, -1)]
780
- rows.append(f"| {byte} | " + " | ".join(row) + " |")
781
- legend = "\n".join(f"{i}. `{s.name}` — {signal_bits(s)}" for i, s in enumerate(sigs, 1))
782
- return "\n".join(rows) + "\n\n" + legend
808
+ owners = [tuple(",".join(map(str, cells.get(byte * 8 + bit, []))) or "·" for bit in range(7, -1, -1))
809
+ for byte in range(frame.length)]
810
+ first = 0
811
+ for byte in range(1, frame.length + 1):
812
+ if byte < frame.length and owners[byte] == owners[first]:
813
+ continue
814
+ label = str(first) if byte - 1 == first else f"{first}–{byte - 1}"
815
+ rows.append(f"| {label} | " + " | ".join(owners[first]) + " |")
816
+ first = byte
817
+ return "\n".join(legend) + "\n\n" + "\n".join(rows)
783
818
 
784
819
 
785
820
  # --------------------------------------------------------------------------- #
@@ -718,16 +718,8 @@ def add_node(a: Analysis, path: Path_, kind: str) -> TextChange | None:
718
718
  ("title", yaml_text(_default_title(a, "Шина", "Bus", buses))),
719
719
  ("protocol", "can")])
720
720
  if kind == "device" and len(path) == 2:
721
- devices = raw_at(a, path + ("devices",)) or []
722
- fields = [("name", _unique("device", _names(devices))),
723
- ("title", yaml_text(_default_title(a, "Устройство", "Device", devices)))]
724
- protocol = (raw_at(a, path) or {}).get("protocol", "canopen")
725
- if protocol == "canopen" and (nid := _free_number([d.get("node_id") for d in devices
726
- if isinstance(d, dict)], 1, 127)):
727
- fields.append(("node_id", f"0x{nid:02X}"))
728
- elif protocol == "j1939" and (sa := _free_number([d.get("address") for d in devices
729
- if isinstance(d, dict)], 0, 253)) is not None:
730
- fields.append(("address", f"0x{sa:02X}"))
721
+ device = new_device(a, raw_at(a, path) or {}, raw_at(a, path + ("devices",)) or [])
722
+ fields = [(k, f"0x{v:02X}" if k in ("node_id", "address") else yaml_text(v)) for k, v in device.items()]
731
723
  return append_item(a, path, "devices", fields)
732
724
  if kind == "frame" and path and path[0] == "buses" and len(path) in (2, 4):
733
725
  return append_item(a, path, "frames", _new_frame(a, path))
@@ -736,6 +728,51 @@ def add_node(a: Analysis, path: Path_, kind: str) -> TextChange | None:
736
728
  return None
737
729
 
738
730
 
731
+ def new_device(a: Analysis, bus: dict, devices: list) -> dict:
732
+ """Fields of a new device of a bus with these devices: a free name, title and CANopen node ID or J1939
733
+ address."""
734
+ out = {"name": _unique("device", _names(devices)),
735
+ "title": _default_title(a, "Устройство", "Device", devices)}
736
+ protocol = bus.get("protocol", "canopen")
737
+ if protocol == "canopen" and (nid := _free_number([d.get("node_id") for d in devices
738
+ if isinstance(d, dict)], 1, 127)):
739
+ out["node_id"] = nid
740
+ elif protocol == "j1939" and (sa := _free_number([d.get("address") for d in devices
741
+ if isinstance(d, dict)], 0, 253)) is not None:
742
+ out["address"] = sa
743
+ return out
744
+
745
+
746
+ def device_file(a: Analysis, bus_path: Path_, file: str | None) -> dict:
747
+ """A new device of a bus in a file of its own (#136). Without ``file``: {file} with the default name of the
748
+ file. With it: {device, target, text, ops}: the device, the new file and its text, and the operations that
749
+ add it to ``include`` of the map (paths of the map). ValueError: why it cannot be made."""
750
+ from ..web.devicefile import (
751
+ default_device_file,
752
+ device_file_target,
753
+ device_file_text,
754
+ include_ops,
755
+ named_after_file,
756
+ )
757
+
758
+ data = a.data if isinstance(a.data, dict) else {}
759
+ bus = raw_at_path(data, bus_path)
760
+ if len(bus_path) != 2 or not isinstance(bus, dict) or not isinstance(bus.get("name"), str):
761
+ raise ValueError(_("path not found: {path}", path=list(bus_path)))
762
+ main = a.result.source.file
763
+ if not main or not Path(main).is_file():
764
+ raise ValueError(_("Save the map to a file first."))
765
+ devices = [d for d in bus.get("devices") or [] if isinstance(d, dict)]
766
+ device = new_device(a, bus, devices)
767
+ if file is None:
768
+ return {"file": default_device_file(device["name"])}
769
+ target, line = device_file_target(Path(main), file)
770
+ device = named_after_file(device, target, _names(devices))
771
+ raw = a.result.raw if isinstance(a.result.raw, dict) else {}
772
+ return {"device": device, "target": target, "ops": include_ops(raw.get("include"), line),
773
+ "text": device_file_text(Path(main), target, bus["name"], {**device, "frames": []})}
774
+
775
+
739
776
  class EditRefused(ValueError):
740
777
  """An edit that cannot be made; the message says why."""
741
778
 
@@ -801,10 +838,10 @@ def _add_signal(a: Analysis, path: Path_) -> TextChange | None:
801
838
  start, length, grown = place
802
839
  signals = raw_at(a, path + ("signals",)) or []
803
840
  fields = [("name", _unique("signal", _names(signals))), ("encoding", "raw")]
804
- if length == 8 and start % 8 == 0:
805
- fields += [("byte", str(start // 8)), ("byte_length", "1")]
806
- else:
807
- fields += [("start_bit", str(start)), ("bit_length", str(length))]
841
+ # The position in the notation the other editors write (#117)
842
+ from ..web.yamlops import layout_keys
843
+
844
+ fields +=[(k, str(v)) for k, v in layout_keys(start, length, False, "raw").items()]
808
845
  change = append_item(a, path, "signals", fields)
809
846
  if change is None or grown is None:
810
847
  return change
@@ -1208,6 +1245,8 @@ def set_field(a: Analysis, path: Path_, value) -> list[Edit]:
1208
1245
  return [minimal_edit(a.text, apply_ops(a, [op]))]
1209
1246
  if key == "canopen" and node_kind(path[:-1]) == "frame":
1210
1247
  return _set_frame_type(a, path, value)
1248
+ if key == "sender" and node_kind(path[:-1]) == "frame":
1249
+ return _set_sender(a, path, value)
1211
1250
  if not isinstance(raw_at(a, path[:-1]), dict):
1212
1251
  # A field of a nested mapping that does not exist yet (value.name of an SDO): the mapping is added with it
1213
1252
  parent = path[:-1]
@@ -1270,6 +1309,18 @@ def _set_frame_type(a: Analysis, path: Path_, value) -> list[Edit]:
1270
1309
  return edits
1271
1310
 
1272
1311
 
1312
+ def _set_sender(a: Analysis, path: Path_, value) -> list[Edit]:
1313
+ """The sender of a frame; a device named as the sender does not receive the frame, so it leaves the receivers."""
1314
+ receivers = raw_at(a, path[:-1] + ("receivers",))
1315
+ if not value or not isinstance(receivers, list) or value not in receivers:
1316
+ return _set_plain(a, path, value)
1317
+ rest = [r for r in receivers if r != value]
1318
+ ops = [{"op": "set", "path": list(path), "value": value},
1319
+ {"op": "set", "path": list(path[:-1] + ("receivers",)), "value": rest} if rest
1320
+ else {"op": "remove", "path": list(path[:-1] + ("receivers",))}]
1321
+ return [minimal_edit(a.text, apply_ops(a, ops))]
1322
+
1323
+
1273
1324
  def _set_plain(a: Analysis, path: Path_, value) -> list[Edit]:
1274
1325
  """A scalar field without special rules: a new value, or ``None`` or "" to remove the key."""
1275
1326
  current = raw_at(a, path)
@@ -424,7 +424,11 @@ def edit_command(ls: MapcanServer, *args) -> dict:
424
424
  {error} (a signal that has no room in its frame) or {}.
425
425
 
426
426
  The edit goes to the file the node is written in (for a new node, its owner's; a new bus goes to the main
427
- file): ``uri`` is there when that is another file of the map than the document."""
427
+ file): ``uri`` is there when that is another file of the map than the document. An included file that a
428
+ delete leaves adding nothing to the map (a device's own file, #136) goes instead: {delete: its uri, edits,
429
+ others}, the edits removing it from ``include`` (as mapcan.lsp.ops)."""
430
+ from ..web.devicefile import exclude_ops, part_is_empty
431
+
428
432
  params = _arg(args)
429
433
  uri = params.get("uri", "")
430
434
  a = ls.analysis(uri) if uri else None
@@ -449,6 +453,17 @@ def edit_command(ls: MapcanServer, *args) -> dict:
449
453
  change = None
450
454
  if change is None:
451
455
  return {}
456
+ if action == "delete" and view.own_file and part_is_empty(change.text):
457
+ from ..web.yamlops import OpError
458
+
459
+ raw = a.result.raw if isinstance(a.result.raw, dict) else {}
460
+ main = Path(a.result.source.file or "")
461
+ try:
462
+ out = ls.routed(a, exclude_ops(raw.get("include"), main, Path(view.own_file)), "edits")
463
+ except OpError:
464
+ out = {}
465
+ if out.get("edits") or out.get("others"):
466
+ return {**out, "delete": _file_uri(view.own_file)}
452
467
  e = E.minimal_edit(view.text, change.text)
453
468
  out = {"edit": {"range": [*e.start, *e.end], "text": e.text}, "cursor": list(change.cursor)}
454
469
  if view is not a:
@@ -456,6 +471,33 @@ def edit_command(ls: MapcanServer, *args) -> dict:
456
471
  return out
457
472
 
458
473
 
474
+ @server.command("mapcan.lsp.deviceFile")
475
+ def device_file_command(ls: MapcanServer, *args) -> dict:
476
+ """A new device of a bus in a file of its own (#136). Argument: {uri, path: path of the bus, file}.
477
+ Without ``file``: {file}, the default name of the file relative to the map. With it: {create: {uri, text},
478
+ cursor: the device's name in the new file, edits and others: the line of ``include`` (as mapcan.lsp.ops)},
479
+ or {error}."""
480
+ from ..web.yamlops import OpError
481
+
482
+ params = _arg(args)
483
+ a = ls.analysis(params.get("uri", ""))
484
+ if a is None:
485
+ return {"error": _("The document is not a mapcan map.")}
486
+ file = params.get("file")
487
+ try:
488
+ made = E.device_file(a, tuple(params.get("path") or ()), None if file is None else str(file))
489
+ if file is None:
490
+ return made
491
+ out = ls.routed(a, made["ops"], "edits")
492
+ except (ValueError, OpError) as e:
493
+ return {"error": str(e)}
494
+ lines = made["text"].splitlines()
495
+ # The device is the last named item: the bus comes before it
496
+ line = max(i for i, ln in enumerate(lines) if ln.lstrip().startswith("- name:"))
497
+ return {**out, "create": {"uri": made["target"].as_uri(), "text": made["text"]},
498
+ "cursor": [line, lines[line].index("name:") + len("name: ")]}
499
+
500
+
459
501
  def _params(params) -> tuple[str, tuple]:
460
502
  """(uri, path) from the request parameters {textDocument: {uri}, path}."""
461
503
  get = (lambda o, k: o.get(k)) if isinstance(params, dict) else (lambda o, k: getattr(o, k, None))