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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **A unit that refuses is no longer painted as a unit that is not there.** In
the unit ID scan, a Modbus exception is a reply: the unit exists and it
answered the question with no. It used to get the same red as a unit that
stayed silent. The result cells now say OK, EXCEPTION or NO REPLY, in green,
amber and red, and the message beside them carries the same colour. Filtering
a column offers those three answers rather than a text box.
- **Only the columns worth filtering still offer a filter.** Addr., Bit and BIN
lost theirs: a filter over an address or a row of LEDs answers nothing anyone
asks. HEX and the value columns keep theirs, because a status word or a fault
Expand Down
68 changes: 34 additions & 34 deletions e2e/specs/01-main/12-scan-unitids.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,17 @@ test.describe.serial('Scan Unit IDs', () => {

test('Holding Registers is selected by default', async ({ mainPage }) => {
const modal = mainPage.locator('.MuiModal-root')
const holdingBtn = modal.locator('button.MuiToggleButton-root', {
hasText: 'Holding Registers'
})
const holdingBtn = modal.getByTestId('scan-unitid-type-holding-registers')
await expect(holdingBtn).toHaveClass(/Mui-selected/)
})

test('Coils, Discrete Inputs, and Input Registers are not selected by default', async ({
mainPage
}) => {
const modal = mainPage.locator('.MuiModal-root')
const coilsBtn = modal.locator('button.MuiToggleButton-root', { hasText: 'Coils' })
const diBtn = modal.locator('button.MuiToggleButton-root', { hasText: 'Discrete Inputs' })
const irBtn = modal.locator('button.MuiToggleButton-root', { hasText: 'Input Registers' })
const coilsBtn = modal.getByTestId('scan-unitid-type-coils')
const diBtn = modal.getByTestId('scan-unitid-type-discrete-inputs')
const irBtn = modal.getByTestId('scan-unitid-type-input-registers')

await expect(coilsBtn).not.toHaveClass(/Mui-selected/)
await expect(diBtn).not.toHaveClass(/Mui-selected/)
Expand All @@ -120,15 +118,13 @@ test.describe.serial('Scan Unit IDs', () => {

test('can select multiple register types', async ({ mainPage }) => {
const modal = mainPage.locator('.MuiModal-root')
const coilsBtn = modal.locator('button.MuiToggleButton-root', { hasText: 'Coils' })
const coilsBtn = modal.getByTestId('scan-unitid-type-coils')

await coilsBtn.click()
await expect(coilsBtn).toHaveClass(/Mui-selected/)

// Holding should still be selected (multi-select)
const holdingBtn = modal.locator('button.MuiToggleButton-root', {
hasText: 'Holding Registers'
})
const holdingBtn = modal.getByTestId('scan-unitid-type-holding-registers')
await expect(holdingBtn).toHaveClass(/Mui-selected/)

// Deselect coils again for clean state
Expand All @@ -138,9 +134,7 @@ test.describe.serial('Scan Unit IDs', () => {

test('start button is disabled when no register types selected', async ({ mainPage }) => {
const modal = mainPage.locator('.MuiModal-root')
const holdingBtn = modal.locator('button.MuiToggleButton-root', {
hasText: 'Holding Registers'
})
const holdingBtn = modal.getByTestId('scan-unitid-type-holding-registers')

// Deselect the only selected type
await holdingBtn.click()
Expand Down Expand Up @@ -197,11 +191,26 @@ test.describe.serial('Scan Unit IDs', () => {
await expect(modal.locator('.MuiDataGrid-row[data-id="1"]')).toBeVisible()
})

test('results show OK chip for responding unit IDs', async ({ mainPage }) => {
test('a unit that answered is green and says so', async ({ mainPage }) => {
const modal = mainPage.locator('.MuiModal-root')
// Unit 0 has holding registers — should show OK chip
// Unit 0 has holding registers, so it answered with data.
const row0 = modal.locator('.MuiDataGrid-row[data-id="0"]')
await expect(row0.locator('.MuiChip-colorSuccess')).toBeVisible()
await expect(row0.locator('.scan-answered').first()).toBeVisible()
await expect(row0.locator('.scan-answered').first()).toContainText('OK')
})

test('a unit that refused is amber, not the red of one that said nothing', async ({
mainPage
}) => {
const modal = mainPage.locator('.MuiModal-root')

// Modbux answers for every unit ID it is asked about, and refuses the ones
// it holds no data for, so the rows past the configured units carry a
// refusal rather than silence.
const refused = modal.locator('.scan-refused')
await expect(refused.first()).toBeVisible()
await expect(refused.first()).toContainText('EXCEPTION')
await expect(modal.locator('.scan-silent')).toHaveCount(0)
})

// ─── Scan with multiple register types ──────────────────────────────
Expand All @@ -210,9 +219,9 @@ test.describe.serial('Scan Unit IDs', () => {
test.setTimeout(60000)

const modal = mainPage.locator('.MuiModal-root')
const coilsBtn = modal.locator('button.MuiToggleButton-root', { hasText: 'Coils' })
const diBtn = modal.locator('button.MuiToggleButton-root', { hasText: 'Discrete Inputs' })
const irBtn = modal.locator('button.MuiToggleButton-root', { hasText: 'Input Registers' })
const coilsBtn = modal.getByTestId('scan-unitid-type-coils')
const diBtn = modal.getByTestId('scan-unitid-type-discrete-inputs')
const irBtn = modal.getByTestId('scan-unitid-type-input-registers')

await coilsBtn.click()
await diBtn.click()
Expand All @@ -236,15 +245,6 @@ test.describe.serial('Scan Unit IDs', () => {
)
})

test('the bar counts the unit IDs that answered', async ({ mainPage }) => {
const chip = mainPage.getByTestId('scan-unitid-found-chip')

// Two of the four scanned unit IDs answer on this server.
await expect(chip).toContainText('Found: 2')
await expect(chip).toHaveClass(/MuiChip-filled/)
await expect(chip).toHaveClass(/MuiChip-colorSuccess/)
})

test('multi-type scan results show columns for each type', async ({ mainPage }) => {
const modal = mainPage.locator('.MuiModal-root')

Expand All @@ -259,23 +259,23 @@ test.describe.serial('Scan Unit IDs', () => {
expect(headerText).toContain('Holding')
})

test('unit 0 shows OK for all four types', async ({ mainPage }) => {
test('unit 0 answered for all four types', async ({ mainPage }) => {
const modal = mainPage.locator('.MuiModal-root')
const row0 = modal.locator('.MuiDataGrid-row[data-id="0"]')

// Unit 0 has coils, discrete inputs, holding registers and input registers
const okChips = row0.locator('.MuiChip-colorSuccess')
const count = await okChips.count()
const answered = row0.locator('.scan-answered')
const count = await answered.count()
expect(count).toBe(4)
})

test('unit 1 shows OK for holding, input, and coils', async ({ mainPage }) => {
test('unit 1 answered for holding, input and coils', async ({ mainPage }) => {
const modal = mainPage.locator('.MuiModal-root')
const row1 = modal.locator('.MuiDataGrid-row[data-id="1"]')

// Unit 1 has holding registers, input registers, and coils
const okChips = row1.locator('.MuiChip-colorSuccess')
const count = await okChips.count()
const answered = row1.locator('.scan-answered')
const count = await answered.count()
expect(count).toBeGreaterThanOrEqual(3)
})

Expand Down
17 changes: 17 additions & 0 deletions src/main/modules/modbusClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ type ScanUnitIdFn = ({
registerTypes
}: Omit<ScanUnitIDParameters, 'range' | 'timeout'> & { id: number }) => Promise<void>

/**
* An exception reply rather than silence.
*
* modbus-serial hangs `modbusCode` on the error it builds from an exception
* frame, and nothing else it throws carries one: a timeout is a
* TransactionTimedOutError, a closed port is a PortNotOpenError. So the code
* is the whole test, and it separates a unit that refused a request from a
* unit that was never there.
*/
const isModbusException = (error: unknown): boolean =>
typeof (error as { modbusCode?: unknown })?.modbusCode === 'number'

/** Modbus frames read the way a protocol analyser prints them. */
const toHexString = (bytes: Uint8Array | undefined): string =>
bytes === undefined
Expand Down Expand Up @@ -738,6 +750,7 @@ export class ModbusClient {
const result: ScanUnitIDResult = {
id,
registerTypes: [],
refusedRegisterTypes: [],
requestedRegisterTypes: registerTypes,
errorMessage: {
coils: '',
Expand All @@ -759,6 +772,7 @@ export class ModbusClient {
result.registerTypes.push('coils')
} catch (error) {
result.errorMessage['coils'] = (error as Error).message
if (isModbusException(error)) result.refusedRegisterTypes.push('coils')
}
await this._sendScanProgress()
}
Expand All @@ -775,6 +789,7 @@ export class ModbusClient {
result.registerTypes.push('discrete_inputs')
} catch (error) {
result.errorMessage['discrete_inputs'] = (error as Error).message
if (isModbusException(error)) result.refusedRegisterTypes.push('discrete_inputs')
}
await this._sendScanProgress()
}
Expand All @@ -790,6 +805,7 @@ export class ModbusClient {
result.registerTypes.push('holding_registers')
} catch (error) {
result.errorMessage['holding_registers'] = (error as Error).message
if (isModbusException(error)) result.refusedRegisterTypes.push('holding_registers')
}
await this._sendScanProgress()
}
Expand All @@ -806,6 +822,7 @@ export class ModbusClient {
result.registerTypes.push('input_registers')
} catch (error) {
result.errorMessage['input_registers'] = (error as Error).message
if (isModbusException(error)) result.refusedRegisterTypes.push('input_registers')
}
await this._sendScanProgress()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import LoadDummyDataButton from './LoadDummyDataButton/LoadDummyDataButton'
import MenuConnectionOptions from './MenuConnectionOptions/MenuConnectionOptions'
import MenuRegisterOptions from './MenuRegisterOptions/MenuRegisterOptions'
import ScanRegistersButton, { SetAnchorProps } from './ScanRegistersButton/ScanRegistersButton'
import ScanUnitIds from './ScanUnitIds/ScanUnitIds'
import { ScanUnitIdsButton } from './ScanUnitIds/ScanUnitIds'
import FormGroup from '@mui/material/FormGroup'
import Button from '@mui/material/Button'
import { Settings } from '@mui/icons-material'
Expand All @@ -16,7 +16,7 @@ const MenuContent = meme(({ setAnchor }: SetAnchorProps) => {
<FormGroup>
<MenuRegisterOptions />
<MenuConnectionOptions />
<ScanUnitIds />
<ScanUnitIdsButton setAnchor={setAnchor} />
<ScanRegistersButton setAnchor={setAnchor} />
<LoadDummyDataButton setAnchor={setAnchor} />
</FormGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import {
Button,
Chip,
IconButton,
InputBaseComponentProps,
LinearProgress,
TextField,
Tooltip
Tooltip,
Typography
} from '@mui/material'
import { Close, Visibility, VisibilityOff } from '@mui/icons-material'
import { Visibility, VisibilityOff } from '@mui/icons-material'
import { meme } from '@renderer/components/shared/inputs/meme'
import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types'
import { useRootZustand } from '@renderer/context/root.zustand'
Expand Down Expand Up @@ -96,21 +96,34 @@ export const ScanTimeoutField = meme(
)
)

interface ScanFoundChipProps {
interface ScanFoundCountProps {
count: number
testId: string
}

/** A scan that turns up nothing looks exactly like a scan still warming up. */
export const ScanFoundChip = meme(
({ count, testId }: ScanFoundChipProps): JSX.Element => (
<Chip
size="small"
label={`Found: ${count}`}
color={count > 0 ? 'success' : 'warning'}
variant={count > 0 ? 'filled' : 'outlined'}
/**
* A scan that turns up nothing looks exactly like a scan still warming up, and
* the grid behind shows the first rows rather than how many there are.
*
* Plain text rather than a chip. A chip is a badge on something, and this is a
* reading: it belongs beside the buttons the way a number belongs on a gauge.
* The colour carries the same thing the text does, so `data-found` says it
* once for anything reading the page.
*/
export const ScanFoundCount = meme(
({ count, testId }: ScanFoundCountProps): JSX.Element => (
<Typography
variant="body2"
sx={(theme) => ({
fontFamily: 'monospace',
whiteSpace: 'nowrap',
color: count > 0 ? theme.palette.success.main : theme.palette.warning.main
})}
data-testid={testId}
/>
data-found={count > 0}
>
Found: {count}
</Typography>
)
)

Expand All @@ -124,19 +137,13 @@ interface ScanCloseButtonProps {
* The way out of a scan dialog.
*
* Clicking beside it used to be it, which threw away the scan you were setting
* up on the way to anything else on screen. A button rather than a bare cross,
* so it carries the same weight as the one beside it, and off while a scan
* runs for the same reason the backdrop click was ignored then.
* up on the way to anything else on screen. A button rather than a cross, so
* it carries the same weight as the one beside it, and off while a scan runs
* for the same reason the backdrop click was ignored then.
*/
export const ScanCloseButton = meme(
({ disabled, close, testId }: ScanCloseButtonProps): JSX.Element => (
<Button
color="primary"
startIcon={<Close />}
disabled={disabled}
onClick={close}
data-testid={testId}
>
<Button color="primary" disabled={disabled} onClick={close} data-testid={testId}>
Close
</Button>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInpu
import { dropPendingScanRows, useDataZustand } from '@renderer/context/data.zustand'
import {
ScanCloseButton,
ScanFoundChip,
ScanFoundCount,
ScanGridToggle,
ScanProgress,
ScanTimeoutField
Expand Down Expand Up @@ -198,7 +198,7 @@ const FoundCount = (): JSX.Element | null => {

if (!scanning) return null

return <ScanFoundChip count={count} testId="scan-found-chip" />
return <ScanFoundCount count={count} testId="scan-found-count" />
}

//
Expand Down
Loading