001package jmri.jmrit.z21server;
002
003import java.beans.PropertyChangeEvent;
004import jmri.DccThrottle;
005import jmri.LocoAddress;
006import jmri.ThrottleListener;
007import jmri.Turnout;
008
009import org.slf4j.Logger;
010import org.slf4j.LoggerFactory;
011
012import java.net.InetAddress;
013import java.util.HashMap;
014import java.util.Iterator;
015import java.beans.PropertyChangeListener;
016
017/**
018 * Register and unregister clients, set loco throttle
019 * 
020 * @author Jean-Yves Roda (C) 2023
021 * @author Eckart Meyer (C) 2025 (enhancements, WlanMaus support)
022 */
023
024public class ClientManager implements ThrottleListener {
025
026    private static ClientManager instance;
027    private static final HashMap<InetAddress, AppClient> registeredClients = new HashMap<>();
028    private static final HashMap<Integer, InetAddress> requestedThrottlesList = new HashMap<>(); //temporary store client InetAddress
029    private PropertyChangeListener changeListener = null;
030    private PropertyChangeListener clientListener = null; //the listener will be notified if a client is registered or unregistered
031    public static float speedMultiplier = 1.0f / 128.0f;
032
033    private final static Logger log = LoggerFactory.getLogger(ClientManager.class);
034
035    private ClientManager() {
036    }
037
038/**
039 * Return the one running instance of the client manager.
040 * If there is no instance, create it.
041 * 
042 * @return the client manager instance
043 */
044    synchronized public static ClientManager getInstance() {
045        if (instance == null) {
046            instance = new ClientManager();
047        }
048        return instance;
049    }
050    
051/**
052 * Set the throttle change listener.
053 * 
054 * @param changeListener - the property change listener instance
055 */
056    public void setChangeListener(PropertyChangeListener changeListener) {
057        this.changeListener = changeListener;
058    }
059    
060/**
061 * Set the client change listener.
062 * The listener is called if a new is registered or a registered client is
063 * unregistered.
064 * 
065 * @param clientListener - the property change listener instance
066 */
067    public void setClientListener(PropertyChangeListener clientListener) {
068        this.clientListener = clientListener;
069    }
070    
071/**
072 * Get a hash map of the registered clients, indexed by their InetAddress
073 * 
074 * @return the hash map of registered clients
075 */
076    public HashMap<InetAddress, AppClient> getRegisteredClients() {
077        return registeredClients;
078    }
079    
080// Loco handling
081
082/**
083 * Register a client if not already registered and add a throttle for the given
084 * loco address to the clients list of throttles.
085 * 
086 * @param clientAddress - InetAddress of the client
087 * @param locoAddress - address of a loco
088 */
089    synchronized public void registerLocoIfNeeded(InetAddress clientAddress, int locoAddress) {
090        if (!registeredClients.containsKey(clientAddress)) {
091            AppClient client = new AppClient(clientAddress, changeListener);
092            registeredClients.put(clientAddress, client);
093            if (clientListener != null) {
094                clientListener.propertyChange(new PropertyChangeEvent(this, "client-registered", null, null));
095            }
096        }
097        if (registeredClients.get(clientAddress).getThrottleFromLocoAddress(locoAddress) == null) {
098            // save loco address and client address temporary, so that notifyThrottleFound() knows the client for the Throttle
099            requestedThrottlesList.put(locoAddress, clientAddress);
100            jmri.InstanceManager.throttleManagerInstance().requestThrottle(locoAddress, ClientManager.getInstance()); //results in notifyThrottleFound() (hopefully)
101        }
102    }
103
104/**
105 * Set a JMRI throttle to new speed and direction.
106 * Called when a Z21 client's user changes speed and/or direction.
107 * 
108 * @param clientAddress - the client's InetAddress
109 * @param locoAddress - the loco address
110 * @param speed - the speed to set
111 * @param forward - true of forward, false if reverse
112 */
113    synchronized public void setLocoSpeedAndDirection(InetAddress clientAddress, int locoAddress, int speed, boolean forward) {
114        AppClient client = registeredClients.get(clientAddress);
115        if (client != null) {
116            DccThrottle throttle = client.getThrottleFromLocoAddress(locoAddress);
117            if (throttle != null) {
118                if (throttle.getIsForward() != forward) throttle.setIsForward(forward);
119                throttle.setSpeedSetting(speed * speedMultiplier);
120                setActiveThrottle(client, throttle);
121            } else {
122                log.info("Unable to find throttle for loco {} from client {}", locoAddress, clientAddress);
123            }
124        } else {
125            log.info("App client {} is not registered", clientAddress);
126        }
127    }
128
129/**
130 * Set a JMRI throttle to new function state.
131 * Called when a Z21 client's user changes function status.
132 * 
133 * @param clientAddress - the client's InetAddress
134 * @param locoAddress - the loco address
135 * @param functionNumber - the function number to set
136 * @param functionState - the new state of the function
137 */
138    synchronized public void setLocoFunction(InetAddress clientAddress, int locoAddress, int functionNumber, int functionState) {
139        AppClient client = registeredClients.get(clientAddress);
140        if (client != null) {
141            DccThrottle throttle = client.getThrottleFromLocoAddress(locoAddress);
142            if (throttle != null) {
143                boolean bOn = (functionState & 0x01) == 0x01;
144                if ( (functionState & 0x03) == 0x02) {
145                    log.trace("Toggle! old state: {}", throttle.getFunction(functionNumber));
146                    bOn = !throttle.getFunction(functionNumber);
147                }
148                log.trace("set function {} to value: {}", functionNumber, bOn);
149                throttle.setFunction(functionNumber, bOn);
150                setActiveThrottle(client, throttle);
151            } else {
152                log.info("Unable to find throttle for loco {} from client {}", locoAddress, clientAddress);
153            }
154        } else {
155            log.info("App client {} is not registered", clientAddress);
156        }
157    }
158    
159/**
160 * Return a Z21 LAN_X_LOCO_INFO packet for a given client and loco address
161 * 
162 * @param address - client InetAddress
163 * @param locoAddress - the loco address
164 * @return Z21 LAN_X_LOCO_INFO packet
165 */
166    @edu.umd.cs.findbugs.annotations.SuppressFBWarnings(value = "PZLA_PREFER_ZERO_LENGTH_ARRAYS",
167    justification = "Messages can be of any length, null is used to indicate absence of message for caller")
168    synchronized public byte[] getLocoStatusMessage(InetAddress address, Integer locoAddress) {
169        if (registeredClients.containsKey(address)) {
170            AppClient client = registeredClients.get(address);
171            return client.getLocoStatusMessage(locoAddress);
172        } else {
173            return null;
174        }
175    }
176    
177/**
178 * Set the active (last used) throttle of a client.
179 * 
180 * @param client - the client's AppClient instance
181 * @param throttle - the throttle instance
182 */
183    private void setActiveThrottle(AppClient client, DccThrottle throttle) {
184        if (client.getActiveThrottle() != throttle) {
185            client.setActiveThrottle(throttle);
186            if (clientListener != null) {
187                clientListener.propertyChange(new PropertyChangeEvent(this, "active-throttle", null, null));
188            }
189        }
190    }
191    
192// Turnout handling
193    
194/**
195 * Set a JMRI component to new state.
196 * The component may be a JMRI turnout, light, route, signal mast, signal head or sensor,
197 * depending on a property entry for the component containing the Z21 turnout number.
198 * 
199 * Called when a Z21 client's user changes state of a turnout.
200 * 
201 * @param clientAddress - client's InetAddress
202 * @param turnoutNumber - the Z21 turnout number, starting from 1 as seen on the WlanMaus display (in the Z21 protocol turnouts start with 0).
203 * @param state - true if turnout should be THROWN, false if CLOSED.
204 */
205    synchronized public void setTurnout(InetAddress clientAddress, int turnoutNumber, boolean state) {
206        // state: 
207        // false: set turnout closed
208        // true: set turnout thrown
209        int turnoutState = state ? Turnout.THROWN : Turnout.CLOSED;
210        TurnoutNumberMapHandler.getInstance().setStateForNumber(turnoutNumber + 1, turnoutState);
211    }
212    
213/**
214 * Get a Z21 LAN_X_TURNOUT_INFO packet to be sent to the client fpr a given turnout number.
215 * 
216 * @param address - client's InetAdress
217 * @param turnoutNumber - the Z21 Turnout Number
218 * @return a Z21 LAN_X_TURNOUT_INFO packet
219 */
220    @edu.umd.cs.findbugs.annotations.SuppressFBWarnings(value = "PZLA_PREFER_ZERO_LENGTH_ARRAYS",
221    justification = "Messages can be of any length, null is used to indicate absence of message for caller")
222    synchronized public byte[] getTurnoutStatusMessage(InetAddress address, Integer turnoutNumber) {
223        int state = TurnoutNumberMapHandler.getInstance().getStateForNumber(turnoutNumber + 1);
224        if (state >= 0) {
225            // return LAN_X_TURNOUT_INFO packet
226            // state in byte 7, bits 0 and 1 - WlanMaus displays the state according to byte 7
227            // 0x02 - turnout closed (straight, main line)
228            // 0x01 - turnout thrown (diverging line)
229            // 0x00 - unknown (WlanMaus displays both legs in the turnout symbol)
230            byte[] turnoutPacket =  new byte[9];
231            turnoutPacket[0] = (byte) 0x09;
232            turnoutPacket[1] = (byte) 0x00;
233            turnoutPacket[2] = (byte) 0x40;
234            turnoutPacket[3] = (byte) 0x00;
235            turnoutPacket[4] = (byte) 0x43;
236            turnoutPacket[5] = (byte) (turnoutNumber >> 8); //MSB
237            turnoutPacket[6] = (byte) (turnoutNumber & 0xFF); //LSB
238            turnoutPacket[7] = (byte) 0x00; //preset UNKNOWN
239            if (state == Turnout.CLOSED) {
240                turnoutPacket[7] = (byte) 0x02;
241            }
242            if (state == Turnout.THROWN) {
243                turnoutPacket[7] = (byte) 0x01;
244            }
245            turnoutPacket[8] = ClientManager.xor(turnoutPacket);
246            return turnoutPacket;
247        }
248        return null;
249    }
250    
251// client handling
252
253/**
254 * Send a heartbeat() to the AppClient instance.
255 * 
256 * @param clientAddress - the client's InetAdress
257 */
258    synchronized public void heartbeat(InetAddress clientAddress) {
259        AppClient client = registeredClients.get(clientAddress);
260        if (client != null) client.heartbeat();
261    }
262
263 /**
264  * Check all clients if they have not sent anything for a time peroid (60 seconds).
265  * If the client has expired, remove it from the list.
266  * 
267  * @param removeAll - if true, remove all clients regardless of their expiry time.
268  */
269    synchronized public void handleExpiredClients(boolean removeAll) {
270        HashMap<InetAddress, AppClient> tempMap = new HashMap<>(registeredClients); // to avoid concurrent modification
271        for (AppClient c : tempMap.values()) {
272            if (c.isTimestampExpired()  ||  removeAll) {
273                log.debug("Remove expired client [{}]",c.getAddress());
274                unregisterClient(c.getAddress());
275             }
276        }
277    }
278    
279/**
280 * Unregister a client.
281 * Clean up the AppClient instance to remove listeners from throttles,
282 * Remove client from hash map,
283 * Call client listener to inform about removing the client
284 * 
285 * @param clientAddress - client's InetAddress
286 */
287    synchronized public void unregisterClient(InetAddress clientAddress) {
288        log.info("Remove client [{}]", clientAddress);
289        if (registeredClients.containsKey(clientAddress)) {
290            registeredClients.get(clientAddress).clear();
291        }
292        registeredClients.remove(clientAddress);
293        if (clientListener != null) {
294            clientListener.propertyChange(new PropertyChangeEvent(this, "client-unregistered", null, null));
295        }
296
297        // the list should definitly be empty, so just in case...
298        for (Iterator<HashMap.Entry<Integer, InetAddress>> it = requestedThrottlesList.entrySet().iterator(); it.hasNext(); ) {
299            HashMap.Entry<Integer, InetAddress> e = it.next();
300            if (e.getValue().equals(clientAddress)) {
301                log.error("The list requestedThrottlesList should be empty, but is not. Remove {}", e);
302                it.remove();
303            }
304        }
305    }
306
307// ThrottleListener implementation
308    
309/**
310 * Called from the throttle manager when a requested throttle for a given loco address was found.
311 * The thottle is then added to the list of throttles in the AppClient instance.
312 * 
313 * @param t - the (new) throttle bound to the loco.
314 */
315    @Override
316    synchronized public void notifyThrottleFound(DccThrottle t) {
317        int locoAddress = t.getLocoAddress().getNumber();
318        // add the new throttle to the AppClient instance, which is identified by the clients InetAddress
319        InetAddress client = requestedThrottlesList.get(locoAddress);
320        if (client != null) {
321            registeredClients.get(client).addThrottle(locoAddress, t);
322            requestedThrottlesList.remove(locoAddress); //not needed any more, remove entry
323        }
324    }
325
326/**
327 * Called from the throttle manager when no throttle can be created for a loco address.
328 * 
329 * @param address - loco address
330 * @param reason - a message from the throttle manager
331 */
332    @Override
333    synchronized public void notifyFailedThrottleRequest(LocoAddress address, String reason) {
334        log.info("Unable to get Throttle for loco address {}, reason : {}", address.getNumber(), reason);
335        requestedThrottlesList.remove(address.getNumber());
336    }
337
338/**
339 * Called from the throttle manager to ask if the throttle should be shared or the previous should be disconnected.
340 * For now, we always use shared throttles.
341 * 
342 * @param address - loco address
343 * @param question - STEAL, SHARE or both
344 */
345    @Override
346    synchronized public void notifyDecisionRequired(LocoAddress address, DecisionType question) {
347        jmri.InstanceManager.throttleManagerInstance().responseThrottleDecision(address, ClientManager.getInstance(), ThrottleListener.DecisionType.SHARE);
348    }
349
350    
351/**
352 * Helper to construct the Z21 protocol XOR byte
353 * 
354 * @param packet - Z21 packet
355 * @return the XOR byte
356 */
357    public static byte xor(byte[] packet) {
358        byte xor = (byte) (packet[0] ^ packet[1]);
359        for (int i = 2; i < (packet.length - 1); i++) {
360            xor = (byte) (xor ^ packet[i]);
361        }
362        return xor;
363    }
364
365}