/**@file GSM Radio Resource procedures, GSM 04.18 and GSM 04.08. */ /* * Copyright 2008, 2009, 2010 Free Software Foundation, Inc. * Copyright 2010 Kestrel Signal Processing, Inc. * Copyright 2011, 2012 Range Networks, Inc. * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * This software is distributed under multiple licenses; * see the COPYING file in the main directory for licensing * information for this specific distribuion. * * This use of this software may be subject to additional restrictions. * See the LEGAL file in the main directory for details. */ #include #include #include #include #include "ControlCommon.h" #include "TransactionTable.h" #include "RadioResource.h" #include "SMSControl.h" #include "CallControl.h" #include #include #include "../GPRS/GPRSExport.h" #include #include #include #include #include #undef WARNING using namespace std; using namespace GSM; using namespace Control; extern unsigned allocateRTPPorts(); /** Determine the channel type needed. This is based on GSM 04.08 9.1.8, Table 9.3 and 9.3a. The following is assumed about the global BTS capabilities: - We do not support call reestablishment. - We do not support GPRS. @param RA The request reference from the channel request message. @return channel type code, undefined if not a supported service */ static ChannelType decodeChannelNeeded(unsigned RA) { // This code is based on GSM 04.08 Table 9.9. unsigned RA4 = RA>>4; unsigned RA5 = RA>>5; // Answer to paging, Table 9.9a. // We don't support TCH/H, so it's wither SDCCH or TCH/F. // The spec allows for "SDCCH-only" MS. We won't support that here. // FIXME -- So we probably should not use "any channel" in the paging indications. if (RA5 == 0x04) return TCHFType; // any channel or any TCH. if (RA4 == 0x01) return SDCCHType; // SDCCH if (RA4 == 0x02) return TCHFType; // TCH/F if (RA4 == 0x03) return TCHFType; // TCH/F if ((RA&0xf8) == 0x78 && RA != 0x7f) return PSingleBlock1PhaseType; if ((RA&0xf8) == 0x70) return PSingleBlock2PhaseType; int NECI = gConfig.getNum("GSM.CellSelection.NECI"); if (NECI==0) { if (RA5 == 0x07) return SDCCHType; // MOC or SDCCH procedures if (RA5 == 0x00) return SDCCHType; // location updating } else { if (gConfig.getBool("Control.VEA")) { // Very Early Assignment if (RA5 == 0x07) return TCHFType; // MOC for TCH/F if (RA4 == 0x04) return TCHFType; // MOC, TCH/H sufficient } else { // Early Assignment if (RA5 == 0x07) return SDCCHType; // MOC for TCH/F if (RA4 == 0x04) return SDCCHType; // MOC, TCH/H sufficient } if (RA4 == 0x00) return SDCCHType; // location updating if (RA4 == 0x01) return SDCCHType; // other procedures on SDCCH } // Anything else falls through to here. // We are still ignoring data calls, LMU. return UndefinedCHType; } /** Return true if RA indicates LUR. */ static bool requestingLUR(unsigned RA) { int NECI = gConfig.getNum("GSM.CellSelection.NECI"); if (NECI==0) return ((RA>>5) == 0x00); else return ((RA>>4) == 0x00); } /** Decode RACH bits and send an immediate assignment; may block waiting for a channel for an SOS call. */ static void AccessGrantResponder( unsigned RA, const GSM::Time& when, float RSSI, float timingError) { // RR Establishment. // Immediate Assignment procedure, "Answer from the Network" // GSM 04.08 3.3.1.1.3. // Given a request reference, try to allocate a channel // and send the assignment to the handset on the CCCH. // This GSM's version of medium access control. // Papa Legba, open that door... gReports.incr("OpenBTS.GSM.RR.RACH.TA.All",(int)(timingError)); gReports.incr("OpenBTS.GSM.RR.RACH.RA.All",RA); // Are we holding off new allocations? if (gBTS.hold()) { LOG(NOTICE) << "ignoring RACH due to BTS hold-off"; return; } // Check "when" against current clock to see if we're too late. // Calculate maximum number of frames of delay. // See GSM 04.08 3.3.1.1.2 for the logic here. static const unsigned txInteger = gConfig.getNum("GSM.RACH.TxInteger"); static const int maxAge = GSM::RACHSpreadSlots[txInteger] + GSM::RACHWaitSParam[txInteger]; // Check burst age. int age = gBTS.time() - when; ChannelType chtype = decodeChannelNeeded(RA); int lur = requestingLUR(RA); int gprs = (chtype == PSingleBlock1PhaseType) || (chtype == PSingleBlock2PhaseType); // This is for debugging. if (GPRS::GPRSDebug && gprs) { Time now = gBTS.time(); LOG(NOTICE) << "RACH" <gConfig.getNum("GSM.MS.TA.Max")) { LOG(NOTICE) << "ignoring RACH burst with delay="<load()>gConfig.getNum("GSM.CCCH.AGCH.QMax")) { LOG(CRIT) << "AGCH congestion"; return; } // Check for location update. // This gives LUR a lower priority than other services. // (pat): LUR = Location Update Request Message if (requestingLUR(RA)) { // Don't answer this LUR if it will not leave enough channels open for other operations. if ((int)gBTS.SDCCHAvailable()<=gConfig.getNum("GSM.Channels.SDCCHReserve")) { unsigned waitTime = gBTS.growT3122()/1000; LOG(CRIT) << "LUR congestion, RA=" << RA << " T3122=" << waitTime; const L3ImmediateAssignmentReject reject(L3RequestReference(RA,when),waitTime); LOG(DEBUG) << "LUR rejection, sending " << reject; AGCH->send(reject); return; } } // Allocate the channel according to the needed type indicated by RA. // The returned channel is already open and ready for the transaction. LogicalChannel *LCH = NULL; switch (chtype) { case TCHFType: LCH = gBTS.getTCH(); break; case SDCCHType: LCH = gBTS.getSDCCH(); break; #if 0 // GSM04.08 sec 3.5.2.1.2 case PSingleBlock1PhaseType: case PSingleBlock2PhaseType: { L3RRMessage *msg = GPRS::GPRSProcessRACH(chtype, L3RequestReference(RA,when), RSSI,timingError,AGCH); if (msg) { AGCH->send(*msg); delete msg; } return; } #endif // If we don't support the service, assign to an SDCCH and we can reject it in L3. case UndefinedCHType: LOG(NOTICE) << "RACH burst for unsupported service RA=" << RA; LCH = gBTS.getSDCCH(); break; // We should never be here. default: assert(0); } // Nothing available? if (!LCH) { // Rejection, GSM 04.08 3.3.1.1.3.2. unsigned waitTime = gBTS.growT3122()/1000; // TODO: If all channels are statically allocated for gprs, dont throw an alert. LOG(CRIT) << "congestion, RA=" << RA << " T3122=" << waitTime; const L3ImmediateAssignmentReject reject(L3RequestReference(RA,when),waitTime); LOG(DEBUG) << "rejection, sending " << reject; AGCH->send(reject); return; } // (pat) gprs todo: Notify GPRS that the MS is getting a voice channel. // It may imply abandonment of packet contexts, if the phone does not // support DTM (Dual Transfer Mode.) There may be other housekeeping // for DTM phones; haven't looked into it. // Set the channel physical parameters from the RACH burst. LCH->setPhy(RSSI,timingError,gBTS.clock().systime(when.FN())); gReports.incr("OpenBTS.GSM.RR.RACH.TA.Accepted",(int)(timingError)); // Assignment, GSM 04.08 3.3.1.1.3.1. // Create the ImmediateAssignment message. // Woot!! We got a channel! Thanks to Legba! int initialTA = (int)(timingError + 0.5F); if (initialTA<0) initialTA=0; if (initialTA>62) initialTA=62; const L3ImmediateAssignment assign( L3RequestReference(RA,when), LCH->channelDescription(), L3TimingAdvance(initialTA) ); LOG(INFO) << "sending L3ImmediateAssignment " << assign; // (pat) This call appears to block. // (david) Not anymore. It got fixed in the trunk while you were working on GPRS. // (doug) Adding in a delay to make sure SI5/6 get out before IA. sleepFrames(20); AGCH->send(assign); // On successful allocation, shrink T3122. gBTS.shrinkT3122(); } void* Control::AccessGrantServiceLoop(void*) { while (true) { ChannelRequestRecord *req = gBTS.nextChannelRequest(); if (!req) continue; AccessGrantResponder( req->RA(), req->frame(), req->RSSI(), req->timingError() ); delete req; } return NULL; } void abortInboundHandover(TransactionEntry* transaction, unsigned cause, GSM::LogicalChannel *LCH=NULL) { LOG(DEBUG) << "aborting inbound handover " << *transaction; char ind[100]; unsigned holdoff = gConfig.getNum("GSM.Handover.FailureHoldoff"); sprintf(ind,"IND HANDOVER_FAILURE %u %u %u", transaction->ID(),cause,holdoff); gPeerInterface.sendUntilAck(transaction,ind); if (LCH) LCH->send(HARDRELEASE); gTransactionTable.remove(transaction); } bool Control::SaveHandoverAccess(unsigned handoverReference, float RSSI, float timingError, const GSM::Time& timestamp) { // In this function, we are "BS2" in the ladder diagram. // This is called from L1 when a handover burst arrives. // We will need to use the transaction record to carry the parameters. // We put this here to avoid dealing with the transaction table in L1. TransactionEntry *transaction = gTransactionTable.inboundHandover(handoverReference); if (!transaction) { LOG(ERR) << "no inbound handover with reference " << handoverReference; return false; } if (timingError > gConfig.getNum("GSM.MS.TA.Max")) { // Handover failure. LOG(NOTICE) << "handover failure on due to TA=" << timingError << " for " << *transaction; // RR cause 8: Handover impossible, timing advance out of range abortInboundHandover(transaction,8); return false; } LOG(INFO) << "saving handover access for " << *transaction; transaction->setInboundHandover(RSSI,timingError,gBTS.clock().systime(timestamp)); return true; } void Control::ProcessHandoverAccess(GSM::TCHFACCHLogicalChannel *TCH) { // In this function, we are "BS2" in the ladder diagram. // This is called from the DCCH dispatcher when it gets a HANDOVER_ACCESS primtive. // The information it needs was saved in the transaction table by Control::SaveHandoverAccess. assert(TCH); LOG(DEBUG) << *TCH; TransactionEntry *transaction = gTransactionTable.inboundHandover(TCH); if (!transaction) { LOG(WARNING) << "handover access with no inbound transaction on " << *TCH; TCH->send(HARDRELEASE); return; } // clear handover in transceiver LOG(DEBUG) << *transaction; transaction->channel()->handoverPending(false); // Respond to handset with physical information until we get Handover Complete. int TA = (int)(transaction->inboundTimingError() + 0.5F); if (TA<0) TA=0; if (TA>62) TA=62; unsigned repeatTimeout = gConfig.getNum("GSM.Timer.T3105"); unsigned sendCount = gConfig.getNum("GSM.Ny1"); L3Frame* frame = NULL; while (!frame && sendCount) { TCH->send(L3PhysicalInformation(L3TimingAdvance(TA)),GSM::UNIT_DATA); sendCount--; frame = TCH->recv(repeatTimeout); if (frame && frame->primitive() == HANDOVER_ACCESS) { LOG(NOTICE) << "flushing HANDOVER_ACCESS while waiting for Handover Complete"; delete frame; frame = NULL; } } // Timed out? if (!frame) { LOG(NOTICE) << "timed out waiting for Handover Complete on " << *TCH << " for " << *transaction; // RR cause 4: Abnormal release, no activity on the radio path abortInboundHandover(transaction,4,TCH); return; } // Screwed up channel? if (frame->primitive()!=ESTABLISH) { LOG(NOTICE) << "unexpected primitive waiting for Handover Complete on " << *TCH << ": " << *frame << " for " << *transaction; delete frame; // RR cause 0x62: Message not compatible with protocol state abortInboundHandover(transaction,0x62,TCH); return; } // Get the next frame, should be HandoverComplete. delete frame; frame = TCH->recv(); L3Message* msg = parseL3(*frame); if (!msg) { LOG(NOTICE) << "unparsable message waiting for Handover Complete on " << *TCH << ": " << *frame << " for " << *transaction; delete frame; // RR cause 0x62: Message not compatible with protocol state TCH->send(L3ChannelRelease(0x62)); abortInboundHandover(transaction,0x62,TCH); return; } delete frame; L3HandoverComplete* complete = dynamic_cast(msg); if (!complete) { LOG(NOTICE) << "expecting for Handover Complete on " << *TCH << "but got: " << *msg << " for " << *transaction; delete frame; // RR cause 0x62: Message not compatible with protocol state TCH->send(L3ChannelRelease(0x62)); abortInboundHandover(transaction,0x62,TCH); } delete msg; // Send re-INVITE to the remote party. unsigned RTPPort = allocateRTPPorts(); SIP::SIPState st = transaction->inboundHandoverSendINVITE(RTPPort); if (st == SIP::Fail) { abortInboundHandover(transaction,4,TCH); return; } transaction->GSMState(GSM::HandoverProgress); while (1) { // FIXME - the sip engine should be doing this // FIXME - and checking for timeout // FIXME - and checking for proceeding (stop sending the resends) st = transaction->inboundHandoverCheckForOK(); if (st == SIP::Active) break; if (st == SIP::Fail) { LOG(NOTICE) << "received Fail while waiting for OK"; abortInboundHandover(transaction,4,TCH); return; } } st = transaction->inboundHandoverSendACK(); LOG(DEBUG) << "status of inboundHandoverSendACK: " << st << " for " << *transaction; // Send completion to peer BTS. char ind[100]; sprintf(ind,"IND HANDOVER_COMPLETE %u", transaction->ID()); gPeerInterface.sendUntilAck(transaction,ind); // Update subscriber registry to reflect new registration. if (transaction->SRIMSI().length() && transaction->SRCALLID().length()) { gSubscriberRegistry.addUser(transaction->SRIMSI().c_str(), transaction->SRCALLID().c_str()); } // The call is running. LOG(INFO) << "succesful inbound handover " << *transaction; transaction->GSMState(GSM::Active); callManagementLoop(transaction,TCH); } void Control::HandoverDetermination(const L3MeasurementResults& measurements, SACCHLogicalChannel* SACCH) { // This is called from the SACCH service loop. // Valid measurements? if (measurements.MEAS_VALID()) return; // Got neighbors? unsigned N = measurements.NO_NCELL(); if (N==0) return; if (N == 7) { LOG(DEBUG) << "neighbor cell information not available"; return; } // Is our current signal OK? int myRxLevel = measurements.RXLEV_SUB_SERVING_CELL_dBm(); int localRSSIMin = gConfig.getNum("GSM.Handover.LocalRSSIMin"); LOG(DEBUG) << "myRxLevel=" << myRxLevel << " dBm localRSSIMin=" << localRSSIMin << " dBm"; if (myRxLevel > localRSSIMin) return; // Look at neighbor cell rx levels int best = 0; int bestRxLevel = measurements.RXLEV_NCELL_dBm(best); for (unsigned int i=1; ibestRxLevel) { bestRxLevel = thisRxLevel; best = i; } } // Does the best exceed the current by more than the threshold? int threshold = gConfig.getNum("GSM.Handover.ThresholdDelta"); LOG(DEBUG) << "myRxLevel=" << myRxLevel << " dBm, best neighbor=" << bestRxLevel << " dBm, threshold=" << threshold << " dB"; if (bestRxLevel < (myRxLevel + threshold)) return; // OK. So we will initiate a handover. LOG(DEBUG) << measurements; int BCCH_FREQ_NCELL = measurements.BCCH_FREQ_NCELL(best); int BSIC = measurements.BSIC_NCELL(best); char* peer = gNeighborTable.getAddress(BCCH_FREQ_NCELL,BSIC); if (!peer) { LOG(CRIT) << "measurement for unknown neighbor BCCH_FREQ_NCELL " << BCCH_FREQ_NCELL << " BSIC " << BSIC; return; } if (gNeighborTable.holdingOff(peer)) { LOG(NOTICE) << "skipping handover to " << peer << " due to holdoff"; return; } // Find the transaction record. TransactionEntry* transaction = gTransactionTable.findBySACCH(SACCH); if (!transaction) { LOG(ERR) << "active SACCH with no transaction record: " << *SACCH; return; } if (transaction->GSMState() != GSM::Active) { LOG(DEBUG) << "skipping handover for transaction " << transaction->ID() << " due to state " << transaction->GSMState(); return; } LOG(INFO) << "preparing handover of " << transaction->ID() << " to " << peer << " with downlink RSSI " << bestRxLevel << " dbm"; // The handover reference will be generated by the other BTS. // We don't set the handover reference or state until we get RSP HANDOVER. // Form and send the message. string msg = string("REQ HANDOVER ") + transaction->handoverString(); struct sockaddr_in peerAddr; if (!resolveAddress(&peerAddr,peer)) { LOG(ALERT) << "cannot resolve peer address " << peer; return; } gPeerInterface.sendMessage(&peerAddr,msg.c_str()); } void Control::PagingResponseHandler(const L3PagingResponse* resp, LogicalChannel* DCCH) { assert(resp); assert(DCCH); LOG(INFO) << *resp; // If we got a TMSI, find the IMSI. L3MobileIdentity mobileID = resp->mobileID(); if (mobileID.type()==TMSIType) { char *IMSI = gTMSITable.IMSI(mobileID.TMSI()); if (IMSI) { mobileID = L3MobileIdentity(IMSI); // (pat) Whenever the MS RACHes, we need to alert the SGSN. // Not sure this is necessary in this particular case, but be safe. GPRS::GPRSNotifyGsmActivity(IMSI); free(IMSI); } else { // Don't try too hard to resolve. // The handset is supposed to respond with the same ID type as in the request. // This could be the sign of some kind of DOS attack. LOG(CRIT) << "Paging Reponse with non-valid TMSI"; // Cause 0x60 "Invalid mandatory information" DCCH->send(L3ChannelRelease(0x60)); return; } } else if(mobileID.type()==IMSIType){ //Cause the tmsi table to be touched gTMSITable.TMSI(resp->mobileID().digits()); } // Delete the Mobile ID from the paging list to free up CCCH bandwidth. // ... if it was not deleted by a timer already ... gBTS.pager().removeID(mobileID); // Find the transction table entry that was created when the phone was paged. // We have to look up by mobile ID since the paging entry may have been // erased before this handler was called. That's too bad. // HACK -- We also flush stray transactions until we find what we // are looking for. TransactionEntry* transaction = gTransactionTable.answeredPaging(mobileID); if (!transaction) { LOG(WARNING) << "Paging Reponse with no transaction record for " << mobileID; // Cause 0x41 means "call already cleared". DCCH->send(L3ChannelRelease(0x41)); return; } LOG(INFO) << "paging reponse for transaction " << *transaction; // Set the transaction channel. transaction->channel(DCCH); // We are looking for a mobile-terminated transaction. // The transaction controller will take it from here. switch (transaction->service().type()) { case L3CMServiceType::MobileTerminatedCall: MTCStarter(transaction, DCCH); return; case L3CMServiceType::MobileTerminatedShortMessage: MTSMSController(transaction, DCCH); return; case L3CMServiceType::TestCall: TestCall(transaction, DCCH); return; default: // Flush stray MOC entries. // There should not be any, but... LOG(ERR) << "non-valid paging-state transaction: " << *transaction; gTransactionTable.remove(transaction); // FIXME -- Send a channel release on the DCCH. } } void Control::AssignmentCompleteHandler(const L3AssignmentComplete *confirm, TCHFACCHLogicalChannel *TCH) { // The assignment complete handler is used to // tie together split transactions across a TCH assignment // in non-VEA call setup. assert(TCH); assert(confirm); LOG(DEBUG) << *confirm; // Check the transaction table to know what to do next. TransactionEntry* transaction = gTransactionTable.find(TCH); if (!transaction) { LOG(WARNING) << "No transaction matching channel " << *TCH << " (" << TCH << ")."; throw UnexpectedMessage(); } LOG(INFO) << "service="<service().type(); // These "controller" functions don't return until the call is cleared. switch (transaction->service().type()) { case L3CMServiceType::MobileOriginatedCall: MOCController(transaction,TCH); break; case L3CMServiceType::MobileTerminatedCall: MTCController(transaction,TCH); break; default: LOG(WARNING) << "unsupported service " << transaction->service(); throw UnsupportedMessage(transaction->ID()); } // If we got here, the call is cleared. } void Pager::addID(const L3MobileIdentity& newID, ChannelType chanType, TransactionEntry& transaction, unsigned wLife) { transaction.GSMState(GSM::Paging); transaction.setTimer("3113",wLife); // Add a mobile ID to the paging list for a given lifetime. ScopedLock lock(mLock); // If this ID is already in the list, just reset its timer. // Uhg, another linear time search. // This would be faster if the paging list were ordered by ID. // But the list should usually be short, so it may not be worth the effort. for (PagingEntryList::iterator lp = mPageIDs.begin(); lp != mPageIDs.end(); ++lp) { if (lp->ID()==newID) { LOG(DEBUG) << newID << " already in table"; lp->renew(wLife); mPageSignal.signal(); return; } } // If this ID is new, put it in the list. mPageIDs.push_back(PagingEntry(newID,chanType,transaction.ID(),wLife)); LOG(INFO) << newID << " added to table"; mPageSignal.signal(); } unsigned Pager::removeID(const L3MobileIdentity& delID) { // Return the associated transaction ID, or 0 if none found. LOG(INFO) << delID; ScopedLock lock(mLock); for (PagingEntryList::iterator lp = mPageIDs.begin(); lp != mPageIDs.end(); ++lp) { if (lp->ID()==delID) { unsigned retVal = lp->transactionID(); mPageIDs.erase(lp); return retVal; } } return 0; } unsigned Pager::pageAll() { // Traverse the full list and page all IDs. // Remove expired IDs. // Return the number of IDs paged. // This is a linear time operation. ScopedLock lock(mLock); // Clear expired entries. PagingEntryList::iterator lp = mPageIDs.begin(); while (lp != mPageIDs.end()) { bool expired = lp->expired(); bool defunct = gTransactionTable.find(lp->transactionID()) == NULL; if (!expired && !defunct) ++lp; else { LOG(INFO) << "erasing " << lp->ID(); // Non-responsive, dead transaction? gTransactionTable.removePaging(lp->transactionID()); // remove from the list lp=mPageIDs.erase(lp); } } LOG(INFO) << "paging " << mPageIDs.size() << " mobile(s)"; // Page remaining entries, two at a time if possible. // These PCH send operations are non-blocking. lp = mPageIDs.begin(); while (lp != mPageIDs.end()) { // FIXME -- This completely ignores the paging groups. // HACK -- So we send every page twice. // That will probably mean a different Pager for each subchannel. // See GSM 04.08 10.5.2.11 and GSM 05.02 6.5.2. const L3MobileIdentity& id1 = lp->ID(); ChannelType type1 = lp->type(); ++lp; if (lp==mPageIDs.end()) { // Just one ID left? LOG(DEBUG) << "paging " << id1; gBTS.getPCH(0)->send(L3PagingRequestType1(id1,type1)); gBTS.getPCH(0)->send(L3PagingRequestType1(id1,type1)); break; } // Page by pairs when possible. const L3MobileIdentity& id2 = lp->ID(); ChannelType type2 = lp->type(); ++lp; LOG(DEBUG) << "paging " << id1 << " and " << id2; gBTS.getPCH(0)->send(L3PagingRequestType1(id1,type1,id2,type2)); gBTS.getPCH(0)->send(L3PagingRequestType1(id1,type1,id2,type2)); } return mPageIDs.size(); } size_t Pager::pagingEntryListSize() { ScopedLock lock(mLock); return mPageIDs.size(); } void Pager::start() { if (mRunning) return; mRunning=true; mPagingThread.start((void* (*)(void*))PagerServiceLoopAdapter, (void*)this); } void* Control::PagerServiceLoopAdapter(Pager *pager) { pager->serviceLoop(); return NULL; } void Pager::serviceLoop() { while (mRunning) { LOG(DEBUG) << "Pager blocking for signal"; mLock.lock(); while (mPageIDs.size()==0) mPageSignal.wait(mLock); mLock.unlock(); // page everything pageAll(); // Wait for pending activity to clear the channel. // This wait is what causes PCH to have lower priority than AGCH. unsigned load = gBTS.getPCH()->load(); LOG(DEBUG) << "Pager waiting for " << load << " multiframes"; if (load) sleepFrames(51*load); } } void Pager::dump(ostream& os) const { ScopedLock lock(mLock); PagingEntryList::const_iterator lp = mPageIDs.begin(); while (lp != mPageIDs.end()) { os << lp->ID() << " " << lp->type() << " " << lp->expired() << endl; ++lp; } } // vim: ts=4 sw=4