Skip to content

[automower] Status update via Husqvarna WebSocket API #18630

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 42 commits into
base: main
Choose a base branch
from

Conversation

MikeTheTux
Copy link
Contributor

@MikeTheTux MikeTheTux commented May 1, 2025

[automower] Status update via Husqvarna WebSocket API

Implementation of even base status update of Husqvarna automower via WebSocket API in parallel to the cyclic polling via REST API.

Description

This PR resolves 2 issues:

Removed the numbered 1 ... 50 position and message channels. Motivation:

  • With the WebSocket implementation, no event is missed
  • Historic values (even beyond the 50) can be retrieved via openHAB persistence
    • E.g. via Grafana it is easy to draw the path or a heatmap of the mower
    • 435416129-0a2a5412-94d6-483c-b6f9-2c629f6db02a
  • Much less channels ease up the user interface of this binding

@MikeTheTux MikeTheTux added the enhancement An enhancement or new feature for an existing add-on label May 1, 2025
@MikeTheTux MikeTheTux marked this pull request as draft May 1, 2025 08:43
@MikeTheTux MikeTheTux added the work in progress A PR that is not yet ready to be merged label May 1, 2025
@MikeTheTux MikeTheTux removed the work in progress A PR that is not yet ready to be merged label May 8, 2025
@MikeTheTux MikeTheTux marked this pull request as ready for review May 8, 2025 20:03
MikeTheTux added 6 commits May 9, 2025 22:34
update of timestamp on WSS events

Signed-off-by: Michael Weger <[email protected]>
Signed-off-by: Michael Weger <[email protected]>
Signed-off-by: Michael Weger <[email protected]>
Signed-off-by: Michael Weger <[email protected]>
@MikeTheTux MikeTheTux requested review from J-N-K and lsiepel May 11, 2025 07:36
Signed-off-by: Michael Weger <[email protected]>
Signed-off-by: Michael Weger <[email protected]>
Signed-off-by: Michael Weger <[email protected]>
@MikeTheTux
Copy link
Contributor Author

Ready for review.

@bern77
Copy link
Contributor

bern77 commented May 17, 2025

Having recently purchased a newer Automower model, I was just about to start extending the existing binding to add support for working areas. I see this is almost completely included already in the new version - thanks a lot for your great work!

There's one thing though, I would consider a great addition, i.e. the "StartInWorkArea" action (which allows you to send the mower directly into a work area). Do you think it's feasible for you to add this?
If I can support, please let me know! Thanks 🙂

@MikeTheTux
Copy link
Contributor Author

i.e. the "StartInWorkArea" action (which allows you to send the mower directly into a work area).

As the Automower API supports this command, it will be easy to add.
Should be available within the next few days.
I will also check if there exist other commands that are not yet supported by the binding.

@bern77
Copy link
Contributor

bern77 commented May 18, 2025

One question regarding the channels of group "Work Areas" - in my case these channels don't seem to be updated.
If I query the mower data via API directly (https://api.amc.husqvarna.dev/v1/mowers/) I do see data for the "workAreas" attribute.

@MikeTheTux
Copy link
Contributor Author

One question regarding the channels of group "Work Areas" - in my case these channels don't seem to be updated. If I query the mower data via API directly (https://api.amc.husqvarna.dev/v1/mowers/) I do see data for the "workAreas" attribute.

What does the Thing Property mowerHasWorkAreas state?

Could the issue be caused by orphan items due to the breaking changes of the update?

@bern77
Copy link
Contributor

bern77 commented May 18, 2025

One question regarding the channels of group "Work Areas" - in my case these channels don't seem to be updated. If I query the mower data via API directly (https://api.amc.husqvarna.dev/v1/mowers/) I do see data for the "workAreas" attribute.

What does the Thing Property mowerHasWorkAreas state?

yes

Could the issue be caused by orphan items due to the breaking changes of the update?

I'm actually running this in a dedicated docker container / separate OH instance - it's the only binding and I generated items just for all channels offered by the binding.

I've also noticed that the updates don't seem to happend automatically (I configured 300s).

@MikeTheTux
Copy link
Contributor Author

I've also noticed that the updates don't seem to happend automatically (I configured 300s).

Please enable TRACE logging and share the relevant parts

@bern77
Copy link
Contributor

bern77 commented May 18, 2025

I've also noticed that the updates don't seem to happend automatically (I configured 300s).

Please enable TRACE logging and share the relevant parts

I triggered the update manually, then received this output and nothing else yet:

2025-05-18 19:49:59.200 [TRACE] [wer.internal.things.AutomowerHandler] - Polling mower due to maxQueryFrequency: '3194.473042445 > 1.0'
2025-05-18 19:49:59.547 [TRACE] [automowerconnect.AutomowerConnectApi] - getMower: {"data":{"type":"mower",  ...   }}
2025-05-18 19:50:00.726 [TRACE] [automowerconnect.AutomowerConnectApi] - getMowerMessages: {"data":{"type":"messages", ... }}
2025-05-18 19:50:02.348 [ERROR] [dule.handler.AnnotationActionHandler] - Could not call method 'public void org.openhab.binding.automower.internal.actions.AutomowerActions.poll()' from module type 'automower.poll#d41d8cd98f00b204e9800998ecf8427e'.
java.lang.reflect.InvocationTargetException: null
        at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) ~[?:?]
        at java.lang.reflect.Method.invoke(Unknown Source) ~[?:?]
        at org.openhab.core.automation.internal.module.handler.AnnotationActionHandler.execute(AnnotationActionHandler.java:127) ~[?:?]
        at org.openhab.core.automation.rest.internal.ThingActionsResource.executeThingAction(ThingActionsResource.java:258) ~[?:?]
        at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) ~[?:?]
        at java.lang.reflect.Method.invoke(Unknown Source) ~[?:?]
        at org.apache.cxf.service.invoker.AbstractInvoker.performInvocation(AbstractInvoker.java:179) ~[bundleFile:3.6.5]
        at org.apache.cxf.service.invoker.AbstractInvoker.invoke(AbstractInvoker.java:96) ~[bundleFile:3.6.5]
        at org.apache.cxf.jaxrs.JAXRSInvoker.invoke(JAXRSInvoker.java:201) ~[bundleFile:3.6.5]
        at org.apache.cxf.jaxrs.JAXRSInvoker.invoke(JAXRSInvoker.java:104) ~[bundleFile:3.6.5]
        at org.apache.cxf.interceptor.ServiceInvokerInterceptor$1.run(ServiceInvokerInterceptor.java:59) ~[bundleFile:3.6.5]
        at org.apache.cxf.interceptor.ServiceInvokerInterceptor.handleMessage(ServiceInvokerInterceptor.java:96) ~[bundleFile:3.6.5]
        at org.apache.cxf.phase.PhaseInterceptorChain.doIntercept(PhaseInterceptorChain.java:307) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.ChainInitiationObserver.onMessage(ChainInitiationObserver.java:121) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.http.AbstractHTTPDestination.invoke(AbstractHTTPDestination.java:265) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.servlet.ServletController.invokeDestination(ServletController.java:234) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.servlet.ServletController.invoke(ServletController.java:208) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.servlet.ServletController.invoke(ServletController.java:160) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.servlet.CXFNonSpringServlet.invoke(CXFNonSpringServlet.java:225) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.servlet.AbstractHTTPServlet.handleRequest(AbstractHTTPServlet.java:304) ~[bundleFile:3.6.5]
        at org.apache.cxf.transport.servlet.AbstractHTTPServlet.doPost(AbstractHTTPServlet.java:217) ~[bundleFile:3.6.5]
        at javax.servlet.http.HttpServlet.service(HttpServlet.java:517) ~[bundleFile:4.0.4]
        at org.apache.cxf.transport.servlet.AbstractHTTPServlet.service(AbstractHTTPServlet.java:279) ~[bundleFile:3.6.5]
        at org.ops4j.pax.web.service.spi.servlet.OsgiInitializedServlet.service(OsgiInitializedServlet.java:102) ~[bundleFile:?]
        at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:799) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.servlet.ServletHandler$ChainEnd.doFilter(ServletHandler.java:1656) ~[bundleFile:9.4.57.v20241219]
        at org.ops4j.pax.web.service.spi.servlet.OsgiFilterChain.doFilter(OsgiFilterChain.java:113) ~[bundleFile:?]
        at org.ops4j.pax.web.service.jetty.internal.PaxWebServletHandler.doHandle(PaxWebServletHandler.java:334) ~[bundleFile:?]
        at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:600) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:127) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:235) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:1624) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:233) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1440) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:188) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:505) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1594) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:186) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1355) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.ContextHandlerCollection.handle(ContextHandlerCollection.java:234) ~[bundleFile:9.4.57.v20241219]
        at org.ops4j.pax.web.service.jetty.internal.PrioritizedHandlerCollection.handle(PrioritizedHandlerCollection.java:96) ~[bundleFile:?]
        at org.eclipse.jetty.server.handler.gzip.GzipHandler.handle(GzipHandler.java:722) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:127) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.Server.handle(Server.java:516) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.HttpChannel.lambda$handle$1(HttpChannel.java:487) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.HttpChannel.dispatch(HttpChannel.java:732) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:479) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.server.HttpChannel.run(HttpChannel.java:439) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.runTask(EatWhatYouKill.java:338) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.doProduce(EatWhatYouKill.java:315) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.tryProduce(EatWhatYouKill.java:173) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.produce(EatWhatYouKill.java:137) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.http2.HTTP2Connection.produce(HTTP2Connection.java:193) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.http2.HTTP2Connection.onFillable(HTTP2Connection.java:148) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.http2.HTTP2Connection$FillableCallback.succeeded(HTTP2Connection.java:371) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:105) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.io.ssl.SslConnection$DecryptedEndPoint.onFillable(SslConnection.java:555) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.io.ssl.SslConnection.onFillable(SslConnection.java:410) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.io.ssl.SslConnection$2.succeeded(SslConnection.java:164) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:105) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.io.ChannelEndPoint$1.run(ChannelEndPoint.java:104) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.runTask(EatWhatYouKill.java:338) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.doProduce(EatWhatYouKill.java:315) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.tryProduce(EatWhatYouKill.java:173) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.run(EatWhatYouKill.java:131) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.ReservedThreadExecutor$ReservedThread.run(ReservedThreadExecutor.java:409) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:883) ~[bundleFile:9.4.57.v20241219]
        at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.run(QueuedThreadPool.java:1034) ~[bundleFile:9.4.57.v20241219]
        at java.lang.Thread.run(Unknown Source) [?:?]
Caused by: java.lang.NullPointerException: Cannot invoke "org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.StayOutZones.isDirty()" because the return value of "org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.MowerData.getStayOutZones()" is null
        at org.openhab.binding.automower.internal.things.AutomowerHandler.updateChannelState(AutomowerHandler.java:1113) ~[?:?]
        at org.openhab.binding.automower.internal.things.AutomowerHandler.updateAutomowerState(AutomowerHandler.java:340) ~[?:?]
        at org.openhab.binding.automower.internal.things.AutomowerHandler.poll(AutomowerHandler.java:317) ~[?:?]
        at org.openhab.binding.automower.internal.actions.AutomowerActions.poll(AutomowerActions.java:255) ~[?:?]
        ... 71 more

Signed-off-by: Michael Weger <[email protected]>
Copy link
Contributor

@lsiepel lsiepel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking the time to refactor and add this enhancement. Found some minor issues while looking at all files except the handerls and apiclient. Before i do, i think it is good to agree on how the factory, bridge and mower handerls work together. I left a suggestion to make use of a subscription pattern.

Signed-off-by: Michael Weger <[email protected]>
fixed compiler warnings

Signed-off-by: Michael Weger <[email protected]>
@MikeTheTux
Copy link
Contributor Author

From my point of view all findings are addressed or commented.

@lsiepel
Copy link
Contributor

lsiepel commented Jul 15, 2025

From my point of view all findings are addressed or commented.

There is a conflict to solve.

This reverts commit e46d27f.

Signed-off-by: Michael Weger <[email protected]>
@MikeTheTux
Copy link
Contributor Author

MikeTheTux commented Jul 15, 2025

There is a conflict to solve.

When resolving the conflict in the editor, how can I sign it of using a real email? - found it. Conflict resolved.

@MikeTheTux
Copy link
Contributor Author

MikeTheTux commented Jul 22, 2025

@lsiepel thx for all your efforts on this project!

Can we get this one now merged as well?

As this issue didn't made it into 5.0.0, I have to provide a new upgrade instruction.

@MikeTheTux
Copy link
Contributor Author

I guess this one shall be flagged as breaking-change.

Here is the update info: openhab/openhab-distro#1773

Copy link
Contributor

@lsiepel lsiepel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final review. Some questions and suggestions. covered all files and changes.

public static final ChannelTypeUID CHANNEL_TYPE_STAYOUTZONES_DIRTY = new ChannelTypeUID(BINDING_ID,
"zoneDirtyType");

public static final Map<Integer, String> ERROR = new HashMap<>() {
private static final long serialVersionUID = 1L;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you get a warning ? Don;t understand why this is added here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] AutomowerBindingConstants.java:[193,58] The serializable class does not declare a static final serialVersionUID field of type long

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering what causes this. It is a static class with static constants, it should never be serialized. None of these files of all bindings have this.

public AutomowerHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
super(thing);
this.timeZoneProvider = timeZoneProvider;
this.mowerZoneId = timeZoneProvider.getTimeZone(); // default initializer
}

@Override
public void handleCommand(ChannelUID channelUID, Command command) {
public synchronized void handleCommand(ChannelUID channelUID, Command command) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment regarding synchronized. I would not expect that here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Channel commands are updating the automower status cached in a global variable and then send a request with the updated values. Having several Channel commands in parallel, could lead to inconsistent updates send to the API - e.g. when the user calls:

items.AM430X_Task_Start_01.sendCommand(360);
items.AM430X_Task_Duration_01.sendCommand(480);

I would like to make sure, that the Start update is fully processes before the Duration update is executed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard to get a full overview on my phone. But if the lock’s scope can’t be reduced any more, this is what it is.
Implementation details like this can always be further adjusted

@wborn wborn requested a review from Copilot July 26, 2025 12:47
Copilot

This comment was marked as resolved.

Signed-off-by: Michael Weger <[email protected]>
Signed-off-by: Michael Weger <[email protected]>
Signed-off-by: Michael Weger <[email protected]>
@lsiepel
Copy link
Contributor

lsiepel commented Jul 26, 2025

If you fix the conflict, we are ready to merge. Please. Check the conflict carefully, as we spend some time in getting the markup right.
For reference: #19011

@MikeTheTux
Copy link
Contributor Author

Conflict resolved - was just a single empty line in readme

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement An enhancement or new feature for an existing add-on (potentially) not backward compatible
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants