Skip to content
Open
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
108 changes: 108 additions & 0 deletions .credo.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
%{
configs: [
%{
name: "default",
files: %{
included: [
"lib/"
],
excluded: [
~r"/_build/",
~r"/deps/",
~r"/node_modules/"
]
},
plugins: [],
requires: [],
strict: true,
parse_timeout: 5000,
color: true,
checks: %{
enabled: [
#
# Consistency
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},

#
# Design
#
{Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 1]},
{Credo.Check.Design.TagTODO, [exit_status: 0]},
{Credo.Check.Design.TagFIXME, []},

#
# Readability
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, [priority: :low]},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},

#
# Refactoring
#
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 15]},
{Credo.Check.Refactor.FunctionArity, [max_arity: 6]},
{Credo.Check.Refactor.LongQuoteBlocks, [max_line_count: 500]},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, [max_nesting: 4]},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},

#
# Warnings
#
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.UnsafeExec, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []}
],
disabled: [
# Disabled: macros generate code that legitimately doesn't use all params
{Credo.Check.Warning.UnusedFunctionReturnHelper, []},
# Disabled: pipe vs no pipe is a style choice in our macros
{Credo.Check.Refactor.PipeChainStart, []}
]
}
}
]
}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ opex62541-*.tar

/certs/

/.vscode/
/.vscode/
/.claude/
1 change: 1 addition & 0 deletions .mcp.json
42 changes: 42 additions & 0 deletions lib/opc_ua/common.ex
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,21 @@ defmodule OpcUA.Common do
end
end

@doc """
Batch reads 'value' attribute of multiple nodes in a single OPC-UA request.
Input: list of {%NodeId{}, index} tuples.
Returns {:ok, [{:ok, value} | {:error, reason}]} or {:error, reason}.
"""
@spec read_node_values(GenServer.server(), [{%NodeId{}, integer()}]) ::
{:ok, list()} | {:error, binary()}
def read_node_values(pid, node_ids_with_index) when is_list(node_ids_with_index) do
if(@mix_env != :test) do
GenServer.call(pid, {:read, {:values, node_ids_with_index}})
else
GenServer.call(pid, {:read, {:values, node_ids_with_index}}, :infinity)
end
end

@doc """
Reads 'value' attribute of a node in the server.
Note: If the value is an array you can search a scalar using `index` parameter.
Expand Down Expand Up @@ -639,6 +654,12 @@ defmodule OpcUA.Common do
{:noreply, state}
end

def handle_call({:read, {:values, node_ids_with_index}}, caller_info, state) do
c_args = Enum.map(node_ids_with_index, fn {node_id, index} -> {to_c(node_id), index} end)
call_port(state, :read_node_values, caller_info, c_args)
{:noreply, state}
end

def handle_call({:read, {:value_by_index, {node_id, index}}}, caller_info, state) do
c_args = {to_c(node_id), index}
call_port(state, :read_node_value_by_index, caller_info, c_args)
Expand Down Expand Up @@ -852,6 +873,20 @@ defmodule OpcUA.Common do
state
end

defp handle_c_response({:read_node_values, caller_metadata, {:ok, results}}, state) do
parsed = Enum.map(results, fn
{:ok, value} -> {:ok, parse_batch_value(value)}
{:error, _} = error -> error
end)
GenServer.reply(caller_metadata, {:ok, parsed})
state
end

defp handle_c_response({:read_node_values, caller_metadata, {:error, _} = error}, state) do
GenServer.reply(caller_metadata, error)
state
end

defp handle_c_response({:read_node_value_by_index, caller_metadata, value_response}, state) do
response = parse_value(value_response)
GenServer.reply(caller_metadata, response)
Expand Down Expand Up @@ -969,6 +1004,13 @@ defmodule OpcUA.Common do

defp parse_value(response), do: response

# Parse a single value from batch read response.
# The C layer encodes variant values directly (scalars, arrays, :nil atom).
defp parse_batch_value(:nil), do: nil
defp parse_batch_value(list) when is_list(list), do: Enum.map(list, &parse_c_value/1)
defp parse_batch_value(tuple) when is_tuple(tuple), do: parse_c_value(tuple)
defp parse_batch_value(value), do: value

defp parse_c_value({ns_index, type, name, name_space_uri, server_index}),
do:
ExpandedNodeId.new(
Expand Down
7 changes: 6 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ defmodule Opex62541.MixProject do
package: package(),
source_url: @source_url,
start_permanent: Mix.env() == :prod,
elixirc_options: [warnings_as_errors: true],
compilers: [:cmake] ++ Mix.compilers(),
build_embedded: true,
cmake_lists: "src/",
Expand Down Expand Up @@ -103,7 +104,10 @@ defmodule Opex62541.MixProject do
end

defp aliases do
[docs: ["docs", &copy_images/1]]
[
docs: ["docs", &copy_images/1],
precommit: ["compile", "credo --strict", "format", "test"]
]
end

defp copy_images(_) do
Expand All @@ -125,6 +129,7 @@ defmodule Opex62541.MixProject do
[
{:elixir_cmake, "~> 0.8"},
{:ex_doc, "~> 0.28", only: :dev, runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end
end
105 changes: 104 additions & 1 deletion src/common.c
Original file line number Diff line number Diff line change
Expand Up @@ -3932,7 +3932,110 @@ void handle_read_node_value(void *entity, bool entity_type, const char *req, int
UA_Variant_delete(value);
}

/*
/*
* Batch read 'value' attribute of multiple nodes using UA_Client_Service_read.
* Input: list of {node_id_tuple, index} tuples
* Output: {:ok, [{:ok, value} | {:error, reason}]} | {:error, reason}
*/
void handle_read_node_values(void *entity, bool entity_type, const char *req, int *req_index)
{
int list_count;
if(ei_decode_list_header(req, req_index, &list_count) < 0)
errx(EXIT_FAILURE, ":handle_read_node_values requires a list");

int node_count = list_count;

if(node_count == 0 || node_count > 100) {
send_error_response("einval");
return;
}

// Only client batch read is supported
if(!entity_type) {
// Skip remaining decode
send_error_response("not_supported");
return;
}

UA_ReadValueId *nodesToRead = (UA_ReadValueId *)UA_Array_new(
node_count, &UA_TYPES[UA_TYPES_READVALUEID]);

for(int i = 0; i < node_count; i++) {
int term_size;
if(ei_decode_tuple_header(req, req_index, &term_size) < 0 || term_size != 2)
errx(EXIT_FAILURE, "read_node_values: each element must be a 2-tuple");

nodesToRead[i].nodeId = assemble_node_id(req, req_index);
nodesToRead[i].attributeId = UA_ATTRIBUTEID_VALUE;
nodesToRead[i].indexRange = UA_STRING_NULL;

unsigned long data_index;
ei_decode_ulong(req, req_index, &data_index);
}

// Decode list tail
ei_decode_list_header(req, req_index, &list_count);

UA_ReadRequest readRequest;
UA_ReadRequest_init(&readRequest);
readRequest.nodesToRead = nodesToRead;
readRequest.nodesToReadSize = node_count;

UA_ReadResponse readResponse = UA_Client_Service_read((UA_Client *)entity, readRequest);

// Use dynamic buffer for batch response (max 64KB, within uint16_t limit)
size_t resp_buf_size = ERLCMD_BUF_SIZE * 2;
char *resp = (char *)malloc(resp_buf_size);
int resp_index = sizeof(uint16_t);
resp[resp_index++] = response_id;
ei_encode_version(resp, &resp_index);
ei_encode_tuple_header(resp, &resp_index, 3);
encode_caller_metadata(resp, &resp_index);

if(readResponse.responseHeader.serviceResult != UA_STATUSCODE_GOOD) {
ei_encode_tuple_header(resp, &resp_index, 2);
ei_encode_atom(resp, &resp_index, "error");
const char *status = UA_StatusCode_name(readResponse.responseHeader.serviceResult);
ei_encode_binary(resp, &resp_index, status, strlen(status));
} else {
ei_encode_tuple_header(resp, &resp_index, 2);
ei_encode_atom(resp, &resp_index, "ok");

int result_count = (int)readResponse.resultsSize;
ei_encode_list_header(resp, &resp_index, result_count);

for(int i = 0; i < result_count; i++) {
UA_DataValue *dv = &readResponse.results[i];
if(dv->status != UA_STATUSCODE_GOOD) {
ei_encode_tuple_header(resp, &resp_index, 2);
ei_encode_atom(resp, &resp_index, "error");
const char *status = UA_StatusCode_name(dv->status);
ei_encode_binary(resp, &resp_index, status, strlen(status));
} else {
ei_encode_tuple_header(resp, &resp_index, 2);
ei_encode_atom(resp, &resp_index, "ok");
encode_variant_struct(resp, &resp_index, &dv->value);
}
}
ei_encode_empty_list(resp, &resp_index);
}

if((size_t)resp_index > resp_buf_size) {
free(resp);
UA_ReadResponse_clear(&readResponse);
UA_Array_delete(nodesToRead, node_count, &UA_TYPES[UA_TYPES_READVALUEID]);
send_error_response("overflow");
return;
}

erlcmd_send(resp, resp_index);

free(resp);
UA_ReadResponse_clear(&readResponse);
UA_Array_delete(nodesToRead, node_count, &UA_TYPES[UA_TYPES_READVALUEID]);
}

/*
* Read 'value' of a node in the server.
*/
void handle_read_node_value_by_index(void *entity, bool entity_type, const char *req, int *req_index)
Expand Down
3 changes: 2 additions & 1 deletion src/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,5 @@ void handle_read_node_executable(void *entity, bool entity_type, const char *req
void handle_read_node_event_notifier(void *entity, bool entity_type, const char *req, int *req_index);
void handle_read_node_value(void *entity, bool entity_type, const char *req, int *req_index);
void handle_read_node_value_by_index(void *entity, bool entity_type, const char *req, int *req_index);
void handle_read_node_value_by_data_type(void *entity, bool entity_type, const char *req, int *req_index);
void handle_read_node_value_by_data_type(void *entity, bool entity_type, const char *req, int *req_index);
void handle_read_node_values(void *entity, bool entity_type, const char *req, int *req_index);
1 change: 1 addition & 0 deletions src/opc_ua_client.c
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,7 @@ static struct request_handler request_handlers[] = {
// TODO: Add UA_Server_writeArrayDimensions, inverse name (read)
{"write_node_value", handle_write_node_value},
{"read_node_value", handle_read_node_value},
{"read_node_values", handle_read_node_values},
{"read_node_value_by_index", handle_read_node_value_by_index},
{"read_node_value_by_data_type", handle_read_node_value_by_data_type},
{"write_node_node_id", handle_write_node_node_id},
Expand Down
Loading