Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
081c466
Add read_attributes CLI command; fix ADS symbol binary parser
piratecarrot Apr 8, 2026
b4ec4a9
Add example2 TwinCAT project with TestStruct, Global GVL, and Main POU
piratecarrot Apr 8, 2026
c21b5cf
refactor: reorganize example2 TwinCAT project structure into Globals …
piratecarrot Apr 8, 2026
3ebc1ed
nil check fix
piratecarrot Apr 8, 2026
6d51b78
feat(client): add RestartStatePoller() for post-reconnect state monit…
piratecarrot Apr 8, 2026
e2d7d27
fix(serializer): use subItem.Offset when serializing structs
piratecarrot Apr 27, 2026
c9b7b4c
test(integration): add example3 PLC project and integration tests
piratecarrot Apr 27, 2026
d668aa9
chore(example3): add .gitattributes, .gitignore, rename solution
piratecarrot Apr 27, 2026
ccd456a
chore(example3): untrack .suo binary (now covered by .gitignore)
piratecarrot Apr 27, 2026
c630ed2
chore(example3): untrack .project.~u binary (now covered by .gitignore)
piratecarrot Apr 27, 2026
3da2850
test(integration): add TestSymbolAttributes (initial)
piratecarrot Apr 30, 2026
bc60bf9
test(integration): add TestSymbolAttributes (initial)
piratecarrot Apr 30, 2026
f0d056f
test(integration): add diagnostics + UploadSymbols fallback for attri…
piratecarrot Apr 30, 2026
4f6179e
test(integration): refine attribute expectation to instance-level att…
piratecarrot Apr 30, 2026
da1134c
chore(testing/plc): add PLC testing project files and update TESTING.md
piratecarrot Apr 30, 2026
52a5c23
chore(testing/plc): add example project files (example & smallproject)
piratecarrot Apr 30, 2026
e190bd7
chore: commit staged files
piratecarrot Apr 30, 2026
bb8b61c
chore(testing/plc): reorganize example project into src/ and add AdsG…
piratecarrot Apr 30, 2026
7a90957
Align example PLC symbols with CLI defaults
piratecarrot May 12, 2026
899bb2f
test(plc): rename deterministic TwinCAT fixtures
piratecarrot May 12, 2026
3412e16
test(integration): sync ADS paths and load env files
piratecarrot May 12, 2026
cd60fe1
fix(serializer): support structs without PLC layout metadata
piratecarrot May 12, 2026
63dc735
test(integration): wait for stable subscription snapshots
piratecarrot May 12, 2026
e50b645
fix: UploadInfo requests 24 bytes from SymbolUploadInfo2 (0xF00F)
piratecarrot Jun 12, 2026
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ LineIDs.dbg.bak

# Environment and config files (if you add these)
.env
.env.integration
.env.local
*.local

Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ help:
@echo " test - Run tests on all modules"
@echo " test-root - Run tests on root module only"
@echo " test-cmd - Run tests on cmd module only"
@echo " test-integration - Run integration tests only"
@echo " coverage - Run tests with coverage report"
@echo " coverage-html - Run coverage and open HTML report"
@echo " lint - Run linter on all modules"
Expand All @@ -45,7 +46,7 @@ dev:
################################################################################

.PHONY: test
test: test-root test-cmd
test: test-root test-cmd test-integration

.PHONY: test-root
test-root:
Expand All @@ -57,6 +58,11 @@ test-cmd:
@echo "Running tests on cmd module..."
cd $(CMD_MODULE) && go test ./... -v

.PHONY: test-integration
test-integration:
@echo "Running integration tests..."
go test -v -tags=integration ./test/integration

.PHONY: coverage
coverage:
@echo "Running tests with coverage (root module)..."
Expand Down
51 changes: 51 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Testing

First be sure to provide your ADS target NetId. You can do that either in the shell or via an env file that the integration tests load automatically.

Using PowerShell:
```pwsh
$env:ADS_TARGET_NET_ID = "199.4.42.250.1.1"
```

Using an env file:
```dotenv
ADS_TARGET_NET_ID=199.4.42.250.1.1
ADS_ROUTER_HOST=127.0.0.1
```

Integration tests look for the first existing file in this order:
- the path in `ADS_TEST_ENV_FILE`
- `test/integration/.env.integration`
- `test/integration/.env`
- `.env.integration` at the repo root
- `.env` at the repo root

- Run all unit tests across the repo (excludes integration build-tag tests):
```pwsh
go test ./...
```

- Run all tests (including tests with the integration build tag):
```pwsh
go test -v -tags=integration ./...
```

- Run all tests in the integration package:
```pwsh
go test -v -tags=integration ./test/integration
```

- Run a single top-level test in the integration package:
```pwsh
go test -v -tags=integration ./test/integration -run '^TestReadAllAccessPaths$'
```

- Run a specific subtest (e.g., sint_max inside TestStaticSeed):
```pwsh
go test -v -tags=integration ./test/integration -run '^TestStaticSeed/sint_max$'
```

- List all tests in the package:
```pwsh
go test -list . -tags=integration ./test/integration
```
7 changes: 7 additions & 0 deletions ads-go.code-workspace
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
94 changes: 94 additions & 0 deletions cmd/cli/cmd_attributes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package cli

import (
"fmt"

"github.com/jarmocluyse/ads-go/pkg/ads"
"github.com/jarmocluyse/ads-go/pkg/ads/types"
)

// handleReadAttributes reads a symbol's type info, pragma attribute declarations, and current value.
// Usage: read_attributes [symbol_path] [port]
func handleReadAttributes(args []string, client *ads.Client) {
if len(args) == 0 {
fmt.Println("[ERROR] read_attributes: symbol path required")
fmt.Println(" Usage: read_attributes <symbol_path> [port]")
fmt.Println(" Tip: use list_symbols to discover available symbol paths")
return
}

path := args[0]
var port uint16 = 852

if len(args) > 1 {
fmt.Sscanf(args[1], "%d", &port)
}

sym, err := client.GetSymbol(port, path)
if err != nil {
fmt.Printf("[ERROR] read_attributes: failed to get symbol '%s': %v\n", path, err)
return
}

dt, err := client.GetDataType(sym.Type, port)
if err != nil {
fmt.Printf("[ERROR] read_attributes: failed to get data type '%s': %v\n", sym.Type, err)
return
}

value, err := client.ReadValue(port, path)
if err != nil {
fmt.Printf("[ERROR] read_attributes: failed to read value of '%s': %v\n", path, err)
return
}

fmt.Printf("\n--- Symbol: %s ---\n", sym.Name)
fmt.Printf(" Type: %s (%s, %d bytes)\n", sym.Type, types.ADSDataTypeToString(dt.DataType), sym.Size)
if sym.Comment != "" {
fmt.Printf(" Comment: %s\n", sym.Comment)
}
fmt.Printf(" Value: %v\n", value)

fmt.Printf("\n Symbol Attributes (%d):\n", len(sym.Attributes))
if len(sym.Attributes) == 0 {
fmt.Printf(" (none)\n")
}
for _, attr := range sym.Attributes {
fmt.Printf(" %-30s = %q\n", attr.Name, attr.Value)
}

fmt.Printf("\n Type-level Attributes (%d):\n", len(dt.Attributes))
if len(dt.Attributes) == 0 {
fmt.Printf(" (none)\n")
}
for _, attr := range dt.Attributes {
fmt.Printf(" %-30s = %q\n", attr.Name, attr.Value)
}

if len(dt.SubItems) > 0 {
fmt.Printf("\n Sub-items (%d):\n", len(dt.SubItems))
printAttrSubItems(dt.SubItems, " ")
}

if len(dt.ArrayInfo) > 0 {
fmt.Printf("\n Array dimensions (%d):\n", len(dt.ArrayInfo))
for i, ai := range dt.ArrayInfo {
fmt.Printf(" [%d] start=%d length=%d\n", i, ai.StartIndex, ai.Length)
}
}

fmt.Println()
}

func printAttrSubItems(items []types.AdsDataType, indent string) {
for _, item := range items {
fmt.Printf("%s%-20s : %-20s (offset %d, %d bytes)\n",
indent, item.Name, item.Type, item.Offset, item.Size)
for _, attr := range item.Attributes {
fmt.Printf("%s {attr} %-28s = %q\n", indent, attr.Name, attr.Value)
}
if len(item.SubItems) > 0 {
printAttrSubItems(item.SubItems, indent+" ")
}
}
}
46 changes: 23 additions & 23 deletions cmd/cli/cmd_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
// handleEnableCounter enables or disables the cycle-based integer counter.
// Usage: enable_counter <true|false>
func handleEnableCounter(args []string, client *ads.Client) {
data := "GLOBAL.gIntCounterActive"
data := "GVL_Global.bIntCounterActive"
var port uint16 = 852
if len(args) == 0 {
fmt.Println("[ERROR] Command 'enable_counter': No value provided. Use 'true' or 'false'.")
Expand Down Expand Up @@ -42,7 +42,7 @@ func handleEnableCounter(args []string, client *ads.Client) {
// handleEnableToggle enables or disables the cycle-based boolean toggle.
// Usage: enable_toggle <true|false>
func handleEnableToggle(args []string, client *ads.Client) {
data := "GLOBAL.gBoolToggleActive"
data := "GVL_Global.bBoolToggleActive"
var port uint16 = 852
if len(args) == 0 {
fmt.Println("[ERROR] Command 'enable_toggle': No value provided. Use 'true' or 'false'.")
Expand Down Expand Up @@ -73,7 +73,7 @@ func handleEnableToggle(args []string, client *ads.Client) {
// handleEnableTimedCounter enables or disables the time-based integer counter.
// Usage: enable_timed_counter <true|false>
func handleEnableTimedCounter(args []string, client *ads.Client) {
data := "GLOBAL.gTimedCounterActive"
data := "GVL_Global.bTimedCounterActive"
var port uint16 = 852
if len(args) == 0 {
fmt.Println("[ERROR] Command 'enable_timed_counter': No value provided. Use 'true' or 'false'.")
Expand Down Expand Up @@ -104,7 +104,7 @@ func handleEnableTimedCounter(args []string, client *ads.Client) {
// handleEnableTimedToggle enables or disables the time-based boolean toggle.
// Usage: enable_timed_toggle <true|false>
func handleEnableTimedToggle(args []string, client *ads.Client) {
data := "GLOBAL.gTimedToggleActive"
data := "GVL_Global.bTimedToggleActive"
var port uint16 = 852
if len(args) == 0 {
fmt.Println("[ERROR] Command 'enable_timed_toggle': No value provided. Use 'true' or 'false'.")
Expand Down Expand Up @@ -139,13 +139,13 @@ func handleReadCounters(args []string, client *ads.Client) {

// List of variables to read
vars := []string{
"GLOBAL.gMyIntCounter",
"GLOBAL.gMyBoolToogle",
"GLOBAL.gTimedIntCounter",
"GLOBAL.gTimedBoolToogle",
"GVL_Global.nMyIntCounter",
"GVL_Global.bMyBoolToogle",
"GVL_Global.nTimedIntCounter",
"GVL_Global.bTimedBoolToogle",
}

fmt.Println("[INFO] Reading counter and toggle values:")
fmt.Println("[INFO] Reading counter and toggle values from GVL_Global:")
for _, varName := range vars {
value, err := client.ReadValue(port, varName)
if err != nil {
Expand All @@ -162,26 +162,26 @@ func handleResetCounters(args []string, client *ads.Client) {
var port uint16 = 852

// Reset cycle-based counter
if err := client.WriteValue(port, "GLOBAL.gMyIntCounter", 0); err != nil {
fmt.Printf("[ERROR] Failed to reset gMyIntCounter: %v\n", err)
if err := client.WriteValue(port, "GVL_Global.nMyIntCounter", 0); err != nil {
fmt.Printf("[ERROR] Failed to reset GVL_Global.nMyIntCounter: %v\n", err)
return
}

// Reset cycle-based toggle
if err := client.WriteValue(port, "GLOBAL.gMyBoolToogle", false); err != nil {
fmt.Printf("[ERROR] Failed to reset gMyBoolToogle: %v\n", err)
if err := client.WriteValue(port, "GVL_Global.bMyBoolToogle", false); err != nil {
fmt.Printf("[ERROR] Failed to reset GVL_Global.bMyBoolToogle: %v\n", err)
return
}

// Reset timed counter
if err := client.WriteValue(port, "GLOBAL.gTimedIntCounter", 0); err != nil {
fmt.Printf("[ERROR] Failed to reset gTimedIntCounter: %v\n", err)
if err := client.WriteValue(port, "GVL_Global.nTimedIntCounter", 0); err != nil {
fmt.Printf("[ERROR] Failed to reset GVL_Global.nTimedIntCounter: %v\n", err)
return
}

// Reset timed toggle
if err := client.WriteValue(port, "GLOBAL.gTimedBoolToogle", false); err != nil {
fmt.Printf("[ERROR] Failed to reset gTimedBoolToogle: %v\n", err)
if err := client.WriteValue(port, "GVL_Global.bTimedBoolToogle", false); err != nil {
fmt.Printf("[ERROR] Failed to reset GVL_Global.bTimedBoolToogle: %v\n", err)
return
}

Expand All @@ -195,10 +195,10 @@ func handleReadStatus(args []string, client *ads.Client) {

// List of enable flags to read
flags := []string{
"GLOBAL.gIntCounterActive",
"GLOBAL.gBoolToggleActive",
"GLOBAL.gTimedCounterActive",
"GLOBAL.gTimedToggleActive",
"GVL_Global.nIntCounterActive",
"GVL_Global.bBoolToggleActive",
"GVL_Global.nTimedCounterActive",
"GVL_Global.bTimedToggleActive",
}

fmt.Println("[INFO] Reading enable flag status:")
Expand All @@ -219,7 +219,7 @@ func handleReadStatus(args []string, client *ads.Client) {
// handleSetCyclePeriod sets the cycle period for timed operations.
// Usage: set_period <seconds>
func handleSetCyclePeriod(args []string, client *ads.Client) {
data := "GLOBAL.gCyclePeriod"
data := "GVL_Global.tCyclePeriod"
var port uint16 = 852

if len(args) == 0 {
Expand Down Expand Up @@ -252,7 +252,7 @@ func handleSetCyclePeriod(args []string, client *ads.Client) {
// handleReadCyclePeriod reads the current cycle period.
// Usage: read_period
func handleReadCyclePeriod(args []string, client *ads.Client) {
data := "GLOBAL.gCyclePeriod"
data := "GVL_Global.tCyclePeriod"
var port uint16 = 852

value, err := client.ReadValue(port, data)
Expand Down
11 changes: 6 additions & 5 deletions cmd/cli/cmd_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import (
"fmt"

"github.com/jarmocluyse/ads-go/pkg/ads"
"github.com/jarmocluyse/ads-go/pkg/ads/types"
)

// handleReadValue reads a generic value from the PLC.
// Usage: read_value [symbol_path] [port]
func handleReadValue(args []string, client *ads.Client) {
data := "GLOBAL.gMyInt"
data := "GVL_Global.nMyInt"
var port uint16 = 852

// Parse arguments if provided
Expand All @@ -31,7 +32,7 @@ func handleReadValue(args []string, client *ads.Client) {
// handleReadBool reads a boolean value from the PLC.
// Usage: read_bool [symbol_path] [port]
func handleReadBool(args []string, client *ads.Client) {
data := "GLOBAL.gMyBool"
data := "GVL_Global.bMyBool"
var port uint16 = 852

// Parse arguments if provided
Expand All @@ -53,7 +54,7 @@ func handleReadBool(args []string, client *ads.Client) {
// handleReadObject reads a structured object from the PLC.
// Usage: read_object
func handleReadObject(args []string, client *ads.Client) {
data := "GLOBAL.gMyDUT"
data := "GVL_Global.stMySampleStruct"
var port uint16 = 852
value, err := client.ReadValue(port, data)
if err != nil {
Expand All @@ -66,7 +67,7 @@ func handleReadObject(args []string, client *ads.Client) {
// handleReadArray reads an array from the PLC.
// Usage: read_array
func handleReadArray(args []string, client *ads.Client) {
data := "GLOBAL.gIntArray"
data := "GVL_Global.aIntArray"
var port uint16 = 852
value, err := client.ReadValue(port, data)
if err != nil {
Expand All @@ -87,7 +88,7 @@ func handleListSymbols(args []string, client *ads.Client) {
}

// Use ReadRaw to get symbol upload info
data, err := client.ReadRaw(port, 0xF00C, 0, 24) // ADSReservedIndexGroupSymbolUploadInfo2
data, err := client.ReadRaw(port, uint32(types.ADSReservedIndexGroupSymbolUploadInfo2), 0, 24) // ADSReservedIndexGroupSymbolUploadInfo2
if err != nil {
fmt.Printf("[ERROR] Command 'list_symbols': Failed to get symbol info (port %d): %v\n", port, err)
return
Expand Down
Loading