opc_da_client/opc_da/
errors.rs1use thiserror::Error;
2use windows::core::HRESULT;
3
4pub type OpcResult<T> = Result<T, OpcError>;
6
7#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum OpcError {
11 #[error("COM error: {source} ({})", friendly_hresult_hint(.source.code()).unwrap_or("No hint available"))]
16 Com {
17 #[from]
18 source: windows::core::Error,
19 },
20
21 #[error("Connection failed: {0}")]
23 Connection(String),
24
25 #[error("Server error: {0} (0x{1:08X})")]
27 Server(String, u32),
28
29 #[error("Data conversion failed: {0}")]
31 Conversion(String),
32
33 #[error("Invalid state: {0}")]
35 InvalidState(String),
36
37 #[error("Not implemented: {0}")]
39 NotImplemented(String),
40
41 #[error("Internal error: {0}")]
43 Internal(String),
44}
45
46impl From<anyhow::Error> for OpcError {
47 fn from(err: anyhow::Error) -> Self {
48 Self::Internal(err.to_string())
49 }
50}
51
52impl From<tokio::task::JoinError> for OpcError {
53 fn from(err: tokio::task::JoinError) -> Self {
54 Self::Internal(format!("Async task join failed: {err}"))
55 }
56}
57
58impl From<std::num::TryFromIntError> for OpcError {
59 fn from(err: std::num::TryFromIntError) -> Self {
60 OpcError::Conversion(format!("Integer conversion error: {err}"))
61 }
62}
63
64pub fn format_hresult(hr: HRESULT) -> String {
66 let hex = format!("0x{:08X}", hr.0 as u32);
67 match friendly_hresult_hint(hr) {
68 Some(hint) => format!("{hex}: {hint}"),
69 None => hex,
70 }
71}
72
73pub fn friendly_hresult_hint(hr: HRESULT) -> Option<&'static str> {
75 match hr.0 as u32 {
76 0x80040112 => Some("Server license does not permit OPC client connections"),
77 0x80080005 => Some("Server process failed to start — check if it is installed and running"),
78 0x80070005 => {
79 Some("Access denied — DCOM launch/activation permissions not configured for this user")
80 }
81 0x800706BA => {
82 Some("RPC server unavailable — the target host may be offline or blocking RPC")
83 }
84 0x800706F4 => Some("COM marshalling error — try restarting the OPC server"),
85 0x80040154 => Some("Server is not registered on this machine"),
86 0x80004003 => Some("Invalid pointer (E_POINTER)"),
87 0xC0040004 => Some("Server rejected write — the item may be read-only (OPC_E_BADRIGHTS)"),
88 0xC0040006 => {
89 Some("Data type mismatch — server cannot convert the written value (OPC_E_BADTYPE)")
90 }
91 0xC0040007 => Some("Item ID not found in server address space (OPC_E_UNKNOWNITEMID)"),
92 0xC0040008 => Some("Item ID syntax is invalid for this server (OPC_E_INVALIDITEMID)"),
93 _ => None,
94 }
95}
96
97pub fn friendly_com_hint(error: &OpcError) -> Option<&'static str> {
99 match error {
100 OpcError::Com { source: e } => friendly_hresult_hint(e.code()),
101 _ => None,
102 }
103}
104
105pub(crate) const E_INVALIDARG_HRESULT: u32 = 0x8007_0057;
106
107pub(crate) fn com_hresult(error: &OpcError) -> Option<u32> {
108 match error {
109 OpcError::Com { source } => Some(source.code().0 as u32),
110 _ => None,
111 }
112}
113
114pub(crate) fn is_com_hresult(error: &OpcError, expected: u32) -> bool {
115 com_hresult(error) == Some(expected)
116}
117
118pub(crate) fn contextual_browse_error(
119 error: OpcError,
120 operation: &str,
121 browse_path: &[String],
122 item_name: Option<&str>,
123) -> OpcError {
124 let path = if browse_path.is_empty() {
125 "<root>".to_string()
126 } else {
127 browse_path
128 .iter()
129 .map(|part| format!("{part:?}"))
130 .collect::<Vec<_>>()
131 .join(" > ")
132 };
133 let item = item_name
134 .map(|name| format!(" item {name:?}"))
135 .unwrap_or_default();
136 let hresult = com_hresult(&error)
137 .map(|value| format!("0x{value:08X}"))
138 .unwrap_or_else(|| "N/A".to_string());
139 let hint = friendly_com_hint(&error).unwrap_or("none");
140 let chain = format!("{error:#}");
141
142 tracing::error!(
143 operation = %operation,
144 browse_path = %path,
145 item_name = %item_name.map_or("<none>", |name| name),
146 hresult = %hresult,
147 hint = %hint,
148 chain = %chain,
149 "OPC browse operation failed"
150 );
151
152 OpcError::Internal(format!(
153 "OPC DA {operation} failed at browse path {path}{item}: {error}"
154 ))
155}
156
157pub fn log_opc_error(error: &OpcError, operation: &str) {
166 let hresult = com_hresult(error).map(|value| format!("0x{value:08X}"));
167 let hint = friendly_com_hint(error);
168 let chain = format!("{error:#}");
169
170 tracing::error!(
171 operation = %operation,
172 hresult = hresult.as_deref().unwrap_or("N/A"),
173 hint = hint.unwrap_or("none"),
174 chain = %chain,
175 "OPC operation failed"
176 );
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn extracts_com_hresult_and_matches_expected_code() {
185 let error = OpcError::Com {
186 source: windows::core::Error::from_hresult(HRESULT(E_INVALIDARG_HRESULT as i32)),
187 };
188 assert_eq!(com_hresult(&error), Some(E_INVALIDARG_HRESULT));
189 assert!(is_com_hresult(&error, E_INVALIDARG_HRESULT));
190 assert!(!is_com_hresult(&error, 0));
191 }
192
193 #[test]
194 fn non_com_errors_have_no_hresult() {
195 let error = OpcError::Internal("test".to_string());
196 assert_eq!(com_hresult(&error), None);
197 assert!(!is_com_hresult(&error, E_INVALIDARG_HRESULT));
198 }
199
200 #[test]
201 fn contextual_browse_error_includes_escaped_path_and_item() {
202 let error = contextual_browse_error(
203 OpcError::Internal("synthetic".to_string()),
204 "GetItemID",
205 &[String::from("FCS0528"), "\u{1}".to_string()],
206 Some("\u{1}"),
207 );
208 assert!(matches!(
209 error,
210 OpcError::Internal(message)
211 if message.contains("\"FCS0528\" > \"\\u{1}\"")
212 && message.contains("item \"\\u{1}\"")
213 && message.contains("synthetic")
214 ));
215 }
216}