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:
- The COM smart pointer becomes a class member, so its lifetime is no longer bounded by the function that called
CoInitialize. - Callbacks (the
ItemChangedevent) require a connection-point sink and an apartment that can actually deliver the call — a consolemain()with no message pump will silently never fire an STA event. - Item IDs arrive from your application as
std::string/std::wstring, not as literals, so every call needs a definedBSTRconversion 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(); }
_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));
.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.
- Find the source interface IID and the
ItemChangedDISPID in the generated.tlh(or with the Visual Studio Object Browser against EasyOpcLib 5.2). - Derive your class from
IDispEventSimpleImpl<1, OPC_Client, &IID_of_source_interface>. - Declare the sink map with
SINK_ENTRY_INFOusing that DISPID and an_ATL_FUNC_INFOdescribing the parameter list (typicallyVT_EMPTYreturn,VT_DISPATCHsender,VT_DISPATCHevent-args). - Call
DispEventAdvise(m_client)afterCreateInstanceandDispEventUnadvise(m_client)before releasing. - Only then call the subscribe method with your item arguments and a requested update rate (the samples use
1000ms againstOPCLabs.KitServer.2, itemsSimulation.RandomandTrends.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:
- Run Build > Rebuild Solution and read the Output window, not the Error List. The Error List merges IntelliSense diagnostics with compiler diagnostics.
- Real compiler errors start with
Cnnnn(for exampleC2065,C2039) and carry a file/line from cl.exe. IntelliSense-only entries are taggedIntelliSense:orEnn. - If the build succeeds and only IntelliSense complains, close the solution, delete the
.vsfolder (or the legacy.sdf/ipchfiles), reopen, and let the database rebuild. If it still complains, ignore it. - Confirm the wrapper actually generated: the
.tlhmust exist in$(IntDir)after a rebuild. If it does not, the#importfailed and you have a genuine registration/version problem.
Verification Procedure
- Build for a single platform (x86 or x64) and confirm
.tlhgeneration. - Call
Initialize()and log theHRESULT; a non-zero value stops everything downstream. - Do a synchronous read of a known-good simulated tag first (
Simulation.Random) to prove connectivity, security and bitness before adding subscriptions. - 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.
- 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.
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.