Добавить HTML-справочник CAN v1 и v2
This commit is contained in:
379
doc/CAN_FRAME_PARSE_V1_V2.md
Normal file
379
doc/CAN_FRAME_PARSE_V1_V2.md
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# Разбор CAN-кадров ProtoCAN Boot v1 и SETProtocol v2
|
||||||
|
|
||||||
|
Документ описывает wire-форматы двух протоколов обновления прошивки:
|
||||||
|
|
||||||
|
- **v1** — `templates/c/protocan-boot`, одна команда или 8 байт образа в одном
|
||||||
|
Extended CAN-кадре;
|
||||||
|
- **v2** — `templates/c/set-protocol`, полный кадр SETProtocol разбивается на
|
||||||
|
несколько Extended CAN-кадров.
|
||||||
|
|
||||||
|
Все многобайтные поля payload передаются **little-endian**. CAN ID — 29-битный.
|
||||||
|
Для рабочего кода нужно использовать канонические реализации из `templates`,
|
||||||
|
а приведённый ниже Python-парсер удобен для анализатора, логов и отладки.
|
||||||
|
|
||||||
|
## 1. ProtoCAN Boot v1
|
||||||
|
|
||||||
|
### 1.1. Разметка Extended CAN ID
|
||||||
|
|
||||||
|
```text
|
||||||
|
bits size field
|
||||||
|
28 1 Priority
|
||||||
|
27 1 Route: 0 = host -> device, 1 = device -> host
|
||||||
|
26..24 3 Device Type
|
||||||
|
23..20 4 Device ID
|
||||||
|
19..16 4 Message Type
|
||||||
|
15..0 16 Message Body
|
||||||
|
```
|
||||||
|
|
||||||
|
Формула:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ID = Priority << 28 |
|
||||||
|
Route << 27 |
|
||||||
|
DeviceType << 24 |
|
||||||
|
DeviceID << 20 |
|
||||||
|
MessageType << 16 |
|
||||||
|
MessageBody
|
||||||
|
```
|
||||||
|
|
||||||
|
Типы загрузочных сообщений:
|
||||||
|
|
||||||
|
| Message Type | Имя | Message Body | CAN payload |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| `0x9` | `BOOT_CONTROL` | `SessionID << 8 \| Command` | параметры команды |
|
||||||
|
| `0xA` | `BOOT_DATA_A` | индекс блока | 8 байт слота A |
|
||||||
|
| `0xB` | `BOOT_DATA_B` | индекс блока | 8 байт слота B |
|
||||||
|
| `0xC` | `BOOT_STATUS` | `SessionID << 8 \| Command` | статус и прогресс |
|
||||||
|
| `0xD` | `BOOT_DISCOVERY` | подтип | информация об устройстве |
|
||||||
|
|
||||||
|
Команды `BOOT_CONTROL`:
|
||||||
|
|
||||||
|
| Код | Команда | Payload |
|
||||||
|
|---:|---|---|
|
||||||
|
| `0x01` | `IDENTIFY` | пустой |
|
||||||
|
| `0x02` | `ENTER_BOOT` | пустой |
|
||||||
|
| `0x03` | `BEGIN_IMAGE` | `image_size u32`, `image_crc32 u32` |
|
||||||
|
| `0x04` | `BEGIN_COMPAT` | `product u16`, `hw_min u8`, `hw_max u8`, `version u32` |
|
||||||
|
| `0x05` | `ERASE` | пустой |
|
||||||
|
| `0x06` | `VERIFY` | пустой |
|
||||||
|
| `0x07` | `COMMIT` | пустой |
|
||||||
|
| `0x08` | `CONFIRM` | пустой |
|
||||||
|
| `0x09` | `REBOOT` | пустой |
|
||||||
|
| `0x0A` | `ABORT` | пустой |
|
||||||
|
| `0x0B` | `QUERY_PROGRESS` | пустой |
|
||||||
|
|
||||||
|
`BOOT_STATUS` всегда содержит 8 байт:
|
||||||
|
|
||||||
|
```text
|
||||||
|
offset size field
|
||||||
|
0 1 status
|
||||||
|
1 1 target_slot
|
||||||
|
2 2 next_block u16 LE
|
||||||
|
4 4 running_crc32 u32 LE
|
||||||
|
```
|
||||||
|
|
||||||
|
`BOOT_DISCOVERY` с body `1` содержит:
|
||||||
|
|
||||||
|
```text
|
||||||
|
offset size field
|
||||||
|
0 2 product_type u16 LE
|
||||||
|
2 1 hardware_revision
|
||||||
|
3 1 protocol_version = 1
|
||||||
|
4 4 firmware_version u32 LE
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример запроса `IDENTIFY` для `DeviceType=7`, `DeviceID=13`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CAN ID: 17D90001
|
||||||
|
DLC: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2. Python-парсер v1
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse_v1(can_id: int, data: bytes) -> dict:
|
||||||
|
if not 0 <= can_id <= 0x1FFFFFFF:
|
||||||
|
raise ValueError("неверный Extended CAN ID")
|
||||||
|
if len(data) > 8:
|
||||||
|
raise ValueError("DLC больше 8")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"version": 1,
|
||||||
|
"priority": (can_id >> 28) & 0x01,
|
||||||
|
"route": (can_id >> 27) & 0x01,
|
||||||
|
"device_type": (can_id >> 24) & 0x07,
|
||||||
|
"device_id": (can_id >> 20) & 0x0F,
|
||||||
|
"message_type": (can_id >> 16) & 0x0F,
|
||||||
|
"message_body": can_id & 0xFFFF,
|
||||||
|
"data": bytes(data),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_type = result["message_type"]
|
||||||
|
body = result["message_body"]
|
||||||
|
if msg_type in (0x9, 0xC):
|
||||||
|
result["session_id"] = (body >> 8) & 0xFF
|
||||||
|
result["command"] = body & 0xFF
|
||||||
|
elif msg_type in (0xA, 0xB):
|
||||||
|
result["slot"] = msg_type - 0xA
|
||||||
|
result["block_index"] = body
|
||||||
|
|
||||||
|
if msg_type == 0xC:
|
||||||
|
if len(data) != 8:
|
||||||
|
raise ValueError("BOOT_STATUS должен содержать 8 байт")
|
||||||
|
result.update({
|
||||||
|
"status": data[0],
|
||||||
|
"target_slot": data[1],
|
||||||
|
"next_block": int.from_bytes(data[2:4], "little"),
|
||||||
|
"running_crc32": int.from_bytes(data[4:8], "little"),
|
||||||
|
})
|
||||||
|
elif msg_type == 0xD and body == 1:
|
||||||
|
if len(data) != 8:
|
||||||
|
raise ValueError("BOOT_DISCOVERY должен содержать 8 байт")
|
||||||
|
result.update({
|
||||||
|
"product_type": int.from_bytes(data[0:2], "little"),
|
||||||
|
"hardware_revision": data[2],
|
||||||
|
"protocol_version": data[3],
|
||||||
|
"firmware_version": int.from_bytes(data[4:8], "little"),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. SETProtocol v2 поверх classic CAN
|
||||||
|
|
||||||
|
В v2 CAN-кадр является только транспортным сегментом. Сначала нужно собрать
|
||||||
|
полный SETP-пакет, и только затем разбирать его заголовок, payload и CRC32.
|
||||||
|
|
||||||
|
### 2.1. Разметка Extended CAN ID
|
||||||
|
|
||||||
|
```text
|
||||||
|
bits size field
|
||||||
|
28..24 5 Prefix = 0x12
|
||||||
|
23..16 8 Destination node
|
||||||
|
15..8 8 Source node
|
||||||
|
7 1 Priority
|
||||||
|
6..0 7 Channel
|
||||||
|
```
|
||||||
|
|
||||||
|
Формула:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ID = 0x12 << 24 |
|
||||||
|
Destination << 16 |
|
||||||
|
Source << 8 |
|
||||||
|
Priority << 7 |
|
||||||
|
Channel
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2. CAN-сегменты
|
||||||
|
|
||||||
|
Первый байт CAN payload — PCI:
|
||||||
|
|
||||||
|
| PCI | Назначение | Формат CAN payload |
|
||||||
|
|---:|---|---|
|
||||||
|
| `0x10` | первый сегмент | `10`, `total_length u16 LE`, первые 5 байт SETP |
|
||||||
|
| `0x20..0x2F` | продолжение | `2N`, следующие 1–7 байт SETP |
|
||||||
|
| `0x30..0x32` | flow control | `3S`, `block_size`, `st_min_ms` |
|
||||||
|
|
||||||
|
`N` — циклический номер сегмента `1..15,0..`; следующий сегмент обязан иметь
|
||||||
|
ожидаемый номер, тот же CAN ID и прийти до тайм-аута сборки 500 мс.
|
||||||
|
|
||||||
|
### 2.3. Внутренний кадр SETProtocol v2
|
||||||
|
|
||||||
|
```text
|
||||||
|
offset size field
|
||||||
|
0 2 SOF = A5 5A
|
||||||
|
2 1 version = 02
|
||||||
|
3 1 flags
|
||||||
|
4 2 message_type u16 LE
|
||||||
|
6 2 source u16 LE
|
||||||
|
8 2 destination u16 LE
|
||||||
|
10 2 sequence u16 LE
|
||||||
|
12 2 payload_length u16 LE
|
||||||
|
14 N payload
|
||||||
|
14+N 4 CRC32 IEEE u32 LE
|
||||||
|
```
|
||||||
|
|
||||||
|
CRC32 считается по байтам от `version` на offset 2 до конца payload. Поля
|
||||||
|
`source`, `destination` и `priority` внутреннего заголовка должны совпадать с
|
||||||
|
CAN ID.
|
||||||
|
|
||||||
|
Флаги:
|
||||||
|
|
||||||
|
| Бит | Значение |
|
||||||
|
|---:|---|
|
||||||
|
| `0x01` | RESPONSE |
|
||||||
|
| `0x02` | EVENT |
|
||||||
|
| `0x04` | ERROR |
|
||||||
|
| `0x08` | ACK_REQUIRED |
|
||||||
|
| `0x10` | MORE |
|
||||||
|
| `0x20` | PRIORITY |
|
||||||
|
|
||||||
|
Каждый response начинается с `status u16 LE`. Основные firmware message types:
|
||||||
|
`FW_BEGIN=0x0100`, `FW_DATA=0x0101`, `FW_END=0x0102`, `FW_ABORT=0x0103`,
|
||||||
|
`FW_STATUS=0x0104`, `FW_ACTIVATE=0x0105`.
|
||||||
|
|
||||||
|
Пример `PING` к BALZAM node `13`, source `0`, sequence `1`, priority `1`,
|
||||||
|
channel `1`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Полный SETP:
|
||||||
|
A5 5A 02 28 01 00 00 00 0D 00 01 00 00 00 E7 29 51 40
|
||||||
|
|
||||||
|
CAN ID 120D0081, сегменты:
|
||||||
|
10 12 00 A5 5A 02 28 01
|
||||||
|
21 00 00 00 0D 00 01 00
|
||||||
|
22 00 00 E7 29 51 40
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4. Python-парсер и сборщик v2
|
||||||
|
|
||||||
|
```python
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
|
||||||
|
def parse_v2_can_id(can_id: int) -> dict:
|
||||||
|
if not 0 <= can_id <= 0x1FFFFFFF:
|
||||||
|
raise ValueError("неверный Extended CAN ID")
|
||||||
|
if (can_id >> 24) & 0x1F != 0x12:
|
||||||
|
raise ValueError("не SETProtocol v2 CAN ID")
|
||||||
|
return {
|
||||||
|
"destination": (can_id >> 16) & 0xFF,
|
||||||
|
"source": (can_id >> 8) & 0xFF,
|
||||||
|
"priority": (can_id >> 7) & 0x01,
|
||||||
|
"channel": can_id & 0x7F,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_setp(packet: bytes, can_id: int) -> dict:
|
||||||
|
if len(packet) < 18 or packet[:2] != b"\xA5\x5A":
|
||||||
|
raise ValueError("нет полного SETP-кадра")
|
||||||
|
if packet[2] != 2:
|
||||||
|
raise ValueError("неподдерживаемая версия SETP")
|
||||||
|
flags = packet[3]
|
||||||
|
if flags & 0xC0:
|
||||||
|
raise ValueError("установлены зарезервированные флаги")
|
||||||
|
payload_length = int.from_bytes(packet[12:14], "little")
|
||||||
|
if len(packet) != 14 + payload_length + 4:
|
||||||
|
raise ValueError("не совпадает payload_length")
|
||||||
|
expected_crc = int.from_bytes(packet[-4:], "little")
|
||||||
|
actual_crc = binascii.crc32(packet[2:-4]) & 0xFFFFFFFF
|
||||||
|
if actual_crc != expected_crc:
|
||||||
|
raise ValueError("ошибка CRC32 SETP")
|
||||||
|
|
||||||
|
address = parse_v2_can_id(can_id)
|
||||||
|
source = int.from_bytes(packet[6:8], "little")
|
||||||
|
destination = int.from_bytes(packet[8:10], "little")
|
||||||
|
priority = int(bool(flags & 0x20))
|
||||||
|
if (source, destination, priority) != (
|
||||||
|
address["source"], address["destination"], address["priority"]
|
||||||
|
):
|
||||||
|
raise ValueError("SETP header не совпадает с CAN ID")
|
||||||
|
|
||||||
|
payload = packet[14:-4]
|
||||||
|
result = {
|
||||||
|
"version": 2,
|
||||||
|
"flags": flags,
|
||||||
|
"message_type": int.from_bytes(packet[4:6], "little"),
|
||||||
|
"source": source,
|
||||||
|
"destination": destination,
|
||||||
|
"sequence": int.from_bytes(packet[10:12], "little"),
|
||||||
|
"payload": payload,
|
||||||
|
"can": address,
|
||||||
|
}
|
||||||
|
if flags & 0x01:
|
||||||
|
if len(payload) < 2:
|
||||||
|
raise ValueError("response не содержит status")
|
||||||
|
result["status"] = int.from_bytes(payload[:2], "little")
|
||||||
|
result["body"] = payload[2:]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class V2CanReassembler:
|
||||||
|
def __init__(self, timeout_ms: int = 500):
|
||||||
|
self.timeout_ms = timeout_ms
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.can_id = None
|
||||||
|
self.total = 0
|
||||||
|
self.data = bytearray()
|
||||||
|
self.next_sequence = 1
|
||||||
|
self.deadline_ms = 0
|
||||||
|
|
||||||
|
def feed(self, can_id: int, data: bytes, now_ms: int):
|
||||||
|
parse_v2_can_id(can_id)
|
||||||
|
if not 1 <= len(data) <= 8:
|
||||||
|
raise ValueError("DLC вне диапазона 1..8")
|
||||||
|
if self.can_id is not None and now_ms >= self.deadline_ms:
|
||||||
|
self.reset()
|
||||||
|
raise ValueError("тайм-аут сборки SETP")
|
||||||
|
|
||||||
|
pci_type = data[0] & 0xF0
|
||||||
|
if pci_type == 0x10:
|
||||||
|
if len(data) != 8:
|
||||||
|
raise ValueError("первый сегмент должен иметь DLC 8")
|
||||||
|
total = int.from_bytes(data[1:3], "little")
|
||||||
|
if not 18 <= total <= 530:
|
||||||
|
raise ValueError("неверный размер SETP")
|
||||||
|
self.can_id = can_id
|
||||||
|
self.total = total
|
||||||
|
self.data = bytearray(data[3:])
|
||||||
|
self.next_sequence = 1
|
||||||
|
self.deadline_ms = now_ms + self.timeout_ms
|
||||||
|
return None
|
||||||
|
|
||||||
|
if pci_type == 0x20:
|
||||||
|
sequence = data[0] & 0x0F
|
||||||
|
if (
|
||||||
|
self.can_id is None
|
||||||
|
or can_id != self.can_id
|
||||||
|
or sequence != self.next_sequence
|
||||||
|
or len(data) < 2
|
||||||
|
):
|
||||||
|
self.reset()
|
||||||
|
raise ValueError("ошибка последовательности CAN-сегментов")
|
||||||
|
if len(data) - 1 > self.total - len(self.data):
|
||||||
|
self.reset()
|
||||||
|
raise ValueError("лишние байты CAN-сегмента")
|
||||||
|
self.data.extend(data[1:])
|
||||||
|
self.next_sequence = (self.next_sequence + 1) & 0x0F
|
||||||
|
self.deadline_ms = now_ms + self.timeout_ms
|
||||||
|
if len(self.data) == self.total:
|
||||||
|
packet = bytes(self.data)
|
||||||
|
packet_can_id = self.can_id
|
||||||
|
self.reset()
|
||||||
|
return parse_setp(packet, packet_can_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if pci_type == 0x30:
|
||||||
|
return {"flow_control": data[0] & 0x0F, "data": data[1:]}
|
||||||
|
raise ValueError("неизвестный PCI")
|
||||||
|
```
|
||||||
|
|
||||||
|
В SETGUI эти операции уже реализованы в
|
||||||
|
`third_party/templates/python/setprotocol/can.py`; собственный parser нужен
|
||||||
|
только внешнему анализатору или диагностическому скрипту.
|
||||||
|
|
||||||
|
## 3. Как отличать v1 от v2
|
||||||
|
|
||||||
|
Для используемых сейчас адресов достаточно следующих признаков:
|
||||||
|
|
||||||
|
- v2: верхние пять бит CAN ID равны `0x12`, PCI начинается с `0x10`, `0x2N`
|
||||||
|
или `0x3S`, после reassembly присутствует `A5 5A 02`;
|
||||||
|
- v1: `MessageType` в битах `19..16` равен `0x9..0xD`, каждый кадр разбирается
|
||||||
|
самостоятельно.
|
||||||
|
|
||||||
|
Однако универсальное автоопределение только по одному CAN ID невозможно:
|
||||||
|
комбинация `Priority/Route/DeviceType` v1 теоретически тоже может дать верхнее
|
||||||
|
поле `0x12`, а первый байт firmware data v1 может случайно совпасть с PCI.
|
||||||
|
Надёжный анализатор должен учитывать настроенный режим узла либо подтвердить v2
|
||||||
|
только после сборки кадра с корректными `A5 5A 02`, длиной и CRC32.
|
||||||
|
|
||||||
|
## 4. Канонические исходники
|
||||||
|
|
||||||
|
- v1 ID и state machine: `third_party/templates/c/protocan-boot/src/pcan_boot.c`;
|
||||||
|
- v2 CAN transport: `third_party/templates/c/set-protocol/src/set_can.c`;
|
||||||
|
- v2 frame/CRC: `third_party/templates/c/set-protocol/src/set_protocol.c`;
|
||||||
|
- v2 firmware payload: `third_party/templates/c/set-protocol/src/set_firmware.c`;
|
||||||
|
- Python v2 CAN: `third_party/templates/python/setprotocol/can.py`.
|
||||||
@@ -4,9 +4,22 @@ param()
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$outputPath = Join-Path $PSScriptRoot 'setprotocol.html'
|
$outputPath = Join-Path $PSScriptRoot 'setprotocol.html'
|
||||||
$sourcePath = Join-Path $PSScriptRoot '..\c\set-protocol\docs\SETPROTOCOL.md'
|
$sourcePath = Join-Path $PSScriptRoot '..\c\set-protocol\docs\SETPROTOCOL.md'
|
||||||
|
$canFrameSourcePath = Join-Path $PSScriptRoot 'CAN_FRAME_PARSE_V1_V2.md'
|
||||||
|
|
||||||
$markdown = Get-Content -Raw -LiteralPath $sourcePath -Encoding UTF8
|
function Convert-DocumentationMarkdown {
|
||||||
$html = (ConvertFrom-Markdown -InputObject $markdown).Html
|
param([string]$Path)
|
||||||
|
|
||||||
|
$markdown = Get-Content -Raw -LiteralPath $Path -Encoding UTF8
|
||||||
|
$html = (ConvertFrom-Markdown -InputObject $markdown).Html
|
||||||
|
|
||||||
|
# Wide protocol tables must scroll horizontally instead of squeezing their
|
||||||
|
# contents into unreadable one-character columns on a narrow viewport.
|
||||||
|
$html = $html -replace '<table>', '<div class="table-wrap"><table>'
|
||||||
|
$html = $html -replace '</table>', '</table></div>'
|
||||||
|
return $html
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = Convert-DocumentationMarkdown -Path $sourcePath
|
||||||
$generatedBlock = @"
|
$generatedBlock = @"
|
||||||
<!-- SETPROTOCOL:START -->
|
<!-- SETPROTOCOL:START -->
|
||||||
<article class="card full-doc" data-source="c/set-protocol/docs/SETPROTOCOL.md">
|
<article class="card full-doc" data-source="c/set-protocol/docs/SETPROTOCOL.md">
|
||||||
@@ -15,6 +28,15 @@ $html
|
|||||||
<!-- SETPROTOCOL:END -->
|
<!-- SETPROTOCOL:END -->
|
||||||
"@
|
"@
|
||||||
|
|
||||||
|
$canFrameHtml = Convert-DocumentationMarkdown -Path $canFrameSourcePath
|
||||||
|
$canFrameBlock = @"
|
||||||
|
<!-- CAN-FRAME-PARSE:START -->
|
||||||
|
<article class="card full-doc" data-source="doc/CAN_FRAME_PARSE_V1_V2.md">
|
||||||
|
$canFrameHtml
|
||||||
|
</article>
|
||||||
|
<!-- CAN-FRAME-PARSE:END -->
|
||||||
|
"@
|
||||||
|
|
||||||
$page = Get-Content -Raw -LiteralPath $outputPath -Encoding UTF8
|
$page = Get-Content -Raw -LiteralPath $outputPath -Encoding UTF8
|
||||||
$pattern = '(?s)<!-- SETPROTOCOL:START -->.*?<!-- SETPROTOCOL:END -->'
|
$pattern = '(?s)<!-- SETPROTOCOL:START -->.*?<!-- SETPROTOCOL:END -->'
|
||||||
if ($page -notmatch $pattern) {
|
if ($page -notmatch $pattern) {
|
||||||
@@ -26,6 +48,16 @@ $page = [regex]::Replace($page, $pattern, [System.Text.RegularExpressions.MatchE
|
|||||||
$generatedBlock
|
$generatedBlock
|
||||||
}, 1)
|
}, 1)
|
||||||
|
|
||||||
|
$canFramePattern = '(?s)<!-- CAN-FRAME-PARSE:START -->.*?<!-- CAN-FRAME-PARSE:END -->'
|
||||||
|
if ($page -notmatch $canFramePattern) {
|
||||||
|
throw 'Не найдены маркеры CAN-FRAME-PARSE:START/END в doc/setprotocol.html.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = [regex]::Replace($page, $canFramePattern, [System.Text.RegularExpressions.MatchEvaluator]{
|
||||||
|
param($match)
|
||||||
|
$canFrameBlock
|
||||||
|
}, 1)
|
||||||
|
|
||||||
$page = $page.TrimEnd("`r", "`n") + [Environment]::NewLine
|
$page = $page.TrimEnd("`r", "`n") + [Environment]::NewLine
|
||||||
$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false)
|
$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false)
|
||||||
[System.IO.File]::WriteAllText($outputPath, $page, $utf8WithoutBom)
|
[System.IO.File]::WriteAllText($outputPath, $page, $utf8WithoutBom)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user