QuickOPC-COM C++: Configuring EasyDAClient ItemChanged

Jason IP7 min read
OPC / OPC UAOther ManufacturerTutorial / How-to
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

Overview: What Breaks When You Leave the Sample's main()

The shipped QuickOPC-COM C++ samples (for example the ReadMultipleItems Win32 console project) do everything inside _tmain: CoInitialize(NULL), create the client, read, exit. That structure hides three problems that appear the moment you wrap the client in your own class:

  1. The COM smart pointer becomes a class member, so its lifetime is no longer bounded by the function that called CoInitialize.
  2. Callbacks (the ItemChanged event) require a connection-point sink and an apartment that can actually deliver the call — a console main() with no message pump will silently never fire an STA event.
  3. Item IDs arrive from your application as std::string/std::wstring, not as literals, so every call needs a defined BSTR conversion path.

The type library reference used by the samples is:

#import "libid:FAB7A1E3-3B79-4292-9C3A-DF39A6F65EC1" version(5.2)  // EasyOpcLib

Everything below assumes that same library version. If your installed build exposes a different version attribute, change the version() clause or drop it and let #import take the latest registered one; a mismatch here produces a compile-time "cannot find type library" error, not a runtime fault.

Step 1 — Split the Declaration from the Instantiation

Put the #import in the precompiled header so the generated .tlh/.tli wrapper is produced once and every translation unit sees identical types. Do not put using namespace EasyOpcLib; in a public header — fully qualify instead, otherwise the smart-pointer typedefs collide with anything else in your project namespace.

pch.h / stdafx.h

#include <atlbase.h>
#include <atlcom.h>
#include <atlsafe.h>
#include <comutil.h>
#import "libid:FAB7A1E3-3B79-4292-9C3A-DF39A6F65EC1" version(5.2)  // EasyOpcLib

OPC_Client.h — declaration only. No CreateInstance in a header.

#pragma once
#include "pch.h"
#include <string>

class OPC_Client
{
public:
    OPC_Client();
    ~OPC_Client();

    HRESULT Initialize();          // creates the COM object
    void    Shutdown();            // releases it deterministically
    HRESULT ReadItem(const std::wstring& serverClass,
                     const std::wstring& itemId,
                     VARIANT& value);
private:
    EasyOpcLib::IEasyDAClientPtr m_client;   // _com_ptr_t member, empty until Initialize()
    bool m_comInitialized;
};

OPC_Client.cpp — instantiation lives here.

#include "pch.h"
#include "OPC_Client.h"

OPC_Client::OPC_Client() : m_comInitialized(false) {}

HRESULT OPC_Client::Initialize()
{
    HRESULT hr = ::CoInitializeEx(NULL, COINIT_MULTITHREADED);
    if (hr == RPC_E_CHANGED_MODE) { /* thread already in an STA - accept it */ }
    else if (FAILED(hr)) return hr;
    else m_comInitialized = true;

    hr = m_client.CreateInstance(__uuidof(EasyOpcLib::EasyDAClient));
    return hr;
}

void OPC_Client::Shutdown()
{
    m_client = NULL;                       // Release() BEFORE CoUninitialize
    if (m_comInitialized) { ::CoUninitialize(); m_comInitialized = false; }
}

OPC_Client::~OPC_Client() { Shutdown(); }
Critical: a _com_ptr_t member destructs when the owning object destructs. If that object is a static/global, its destructor can run after CoUninitialize — the resulting Release() call hits a torn-down apartment and you get an access violation or RPC_E_WRONG_THREAD (0x8001010E) at process exit. Always null the pointer explicitly in Shutdown() and call Shutdown() on the same thread that called CoInitializeEx.

Step 2 — std::string, BSTR and the Argument Arrays

The COM interface takes BSTR. Use _bstr_t (from <comutil.h>, already included by the samples) as the conversion vehicle — it owns the allocation and frees it on scope exit.

Source type Conversion Notes
std::wstring _bstr_t(s.c_str()) Preferred. No code-page loss.
std::string _bstr_t(s.c_str()) Converts via the ANSI code page; non-ASCII item IDs can corrupt.
BSTR out-param _bstr_t(bstr, false) false = take ownership, no extra AddRef/copy.
HRESULT OPC_Client::ReadItem(const std::wstring& serverClass,
                             const std::wstring& itemId, VARIANT& value)
{
    if (m_client == NULL) return E_POINTER;
    try {
        _variant_t v = m_client->ReadItemValue(_bstr_t(L""),
                                               _bstr_t(serverClass.c_str()),
                                               _bstr_t(itemId.c_str()));
        return ::VariantCopy(&value, &v);
    }
    catch (const _com_error& e) { return e.Error(); }
}

Multi-item calls take a SAFEARRAY of argument objects — that is why the shipped sample includes <atlsafe.h>. Build it with CComSafeArray<VARIANT>, populating each element with an IDispatch* wrapped in a VARIANT:

CComSafeArray<VARIANT> args(2);
EasyOpcLib::_DAItemGroupArgumentsPtr a1(__uuidof(EasyOpcLib::DAItemGroupArguments));
a1->ServerClass = _bstr_t(L"OPCLabs.KitServer.2");
a1->ItemDescriptor->ItemId = _bstr_t(L"Simulation.Random");
args.SetAt(0, _variant_t((IDispatch*)a1));
Open the generated .tlh in your intermediate directory (Debug/Release folder) and confirm the exact wrapper class names, property names and method signatures before you compile. The .tlh is the authoritative contract for your installed build — treat any signature written from memory as unverified.

Step 3 — Wiring the ItemChanged Event Sink

Subscriptions are asynchronous: the client raises ItemChanged through a connection point on the outgoing (source) dispinterface. In C++ you implement the sink yourself. ATL's IDispEventSimpleImpl is the lowest-friction route because it avoids re-parsing the type library at compile time.

  1. Find the source interface IID and the ItemChanged DISPID in the generated .tlh (or with the Visual Studio Object Browser against EasyOpcLib 5.2).
  2. Derive your class from IDispEventSimpleImpl<1, OPC_Client, &IID_of_source_interface>.
  3. Declare the sink map with SINK_ENTRY_INFO using that DISPID and an _ATL_FUNC_INFO describing the parameter list (typically VT_EMPTY return, VT_DISPATCH sender, VT_DISPATCH event-args).
  4. Call DispEventAdvise(m_client) after CreateInstance and DispEventUnadvise(m_client) before releasing.
  5. Only then call the subscribe method with your item arguments and a requested update rate (the samples use 1000 ms against OPCLabs.KitServer.2, items Simulation.Random and Trends.Ramp (1 min)).
class OPC_Client :
    public CComObjectRootEx<CComMultiThreadModel>,
    public IDispEventSimpleImpl<1, OPC_Client, &__uuidof(EasyOpcLib::DEasyDAClientEvents)>
{
public:
    BEGIN_SINK_MAP(OPC_Client)
        SINK_ENTRY_INFO(1, __uuidof(EasyOpcLib::DEasyDAClientEvents),
                        DISPID_ITEMCHANGED, &OnItemChanged, &s_itemChangedInfo)
    END_SINK_MAP()

    void __stdcall OnItemChanged(IDispatch* sender, IDispatch* eventArgs);
private:
    static _ATL_FUNC_INFO s_itemChangedInfo;
};

_ATL_FUNC_INFO OPC_Client::s_itemChangedInfo =
    { CC_STDCALL, VT_EMPTY, 2, { VT_DISPATCH, VT_DISPATCH } };

Substitute the real source-interface name and DISPID constant from your .tlh; the names above are placeholders for whatever the type library declares.

Why events never arrive

Symptom Cause Fix
Subscribe returns S_OK, handler never called Thread is an STA (CoInitialize/COINIT_APARTMENTTHREADED) with no message pump Run a GetMessage/DispatchMessage loop, or initialize the thread as MTA with COINIT_MULTITHREADED
Handler fires on a random thread; UI update crashes MTA delivery on an RPC worker thread Marshal to your GUI/logic thread (queued signal, PostMessage, or a lock-protected queue)
CO_E_NOTINITIALIZED (0x800401F0) CreateInstance called on a thread that never ran CoInitializeEx Initialize COM per thread, once
REGDB_E_CLASSNOTREG (0x80040154) Wrong bitness — 32-bit component, 64-bit build (or vice versa) Match the project platform to the registered component; verify the CLSID in the registry hive that matches your build
DispEventAdvise returns CONNECT_E_NOCONNECTION Wrong source IID in the sink template Re-read the outgoing interface IID from the .tlh

Step 4 — IntelliSense Errors vs. Real Build Errors

Red squiggles under IEasyDAClientPtr, _bstr_t conversions or #import-generated types are usually IntelliSense artifacts: the IntelliSense engine does not always consume the compiler-generated .tlh/.tli from the intermediate directory. Distinguish the two before you spend time on it:

  1. Run Build > Rebuild Solution and read the Output window, not the Error List. The Error List merges IntelliSense diagnostics with compiler diagnostics.
  2. Real compiler errors start with Cnnnn (for example C2065, C2039) and carry a file/line from cl.exe. IntelliSense-only entries are tagged IntelliSense: or Enn.
  3. If the build succeeds and only IntelliSense complains, close the solution, delete the .vs folder (or the legacy .sdf/ipch files), reopen, and let the database rebuild. If it still complains, ignore it.
  4. Confirm the wrapper actually generated: the .tlh must exist in $(IntDir) after a rebuild. If it does not, the #import failed and you have a genuine registration/version problem.

Verification Procedure

  1. Build for a single platform (x86 or x64) and confirm .tlh generation.
  2. Call Initialize() and log the HRESULT; a non-zero value stops everything downstream.
  3. Do a synchronous read of a known-good simulated tag first (Simulation.Random) to prove connectivity, security and bitness before adding subscriptions.
  4. Add the sink, advise, subscribe at 1000 ms, and log every callback with a timestamp. Confirm the interval matches the requested rate — a much slower rate points to the server's own group update behaviour, not the client.
  5. Stress the shutdown path: create and destroy your wrapper class 100 times in a loop. Leaks or exit-time crashes here expose lifetime ordering bugs that never surface in a single-shot console sample.
Documentation discipline: when you take integration guidance from any community thread, cross-check it against the vendor's official documentation entry before committing it to production code. Vendor support portals such as Siemens Industry Online Support pair moderated discussion with versioned FAQ entry IDs, manuals and firmware notes — the entry ID is the citable artifact, the thread is not.

FAQ

Why does my QuickOPC-COM ItemChanged handler never fire in a C++ console app?

An STA thread delivers connection-point callbacks through the Windows message queue. A console main() that blocks on Sleep() or getchar() never pumps messages, so the event is queued forever. Either run a GetMessage/DispatchMessage loop or initialize the thread with CoInitializeEx(NULL, COINIT_MULTITHREADED).

Where do I put the #import for EasyOpcLib in a multi-file C++ project?

Put #import "libid:FAB7A1E3-3B79-4292-9C3A-DF39A6F65EC1" version(5.2) in the precompiled header so the .tlh/.tli wrapper is generated once and all translation units share identical types. Keep using namespace EasyOpcLib; out of public headers and fully qualify types such as EasyOpcLib::IEasyDAClientPtr instead.

How do I declare EasyDAClient as a class member but create it elsewhere?

Declare EasyOpcLib::IEasyDAClientPtr m_client; in the header — a default-constructed _com_ptr_t holds no object — then call m_client.CreateInstance(__uuidof(EasyOpcLib::EasyDAClient)) in an Initialize() method in the .cpp, after CoInitializeEx on that thread.

Should I fix IntelliSense errors on #import-generated OPC types?

No, if the build succeeds. IntelliSense does not reliably parse the compiler-generated .tlh. Run Rebuild and check the Output window: only Cnnnn compiler errors are real. Deleting the .vs folder usually clears the stale IntelliSense database.

What causes REGDB_E_CLASSNOTREG (0x80040154) when creating the OPC-DA client?

Almost always a bitness mismatch — a 32-bit registered component being requested from a 64-bit process, or the reverse. Match your Visual Studio platform target to the registered component's bitness and re-check the CLSID in the corresponding registry view.

Back to blog